Files
hoelee-blog/src/content/posts/zh/why-my-on-chain-nft-art-changed-on-windows.md
T
hoelee 95b21e3456
Deploy / build (push) Successful in 20s
Add 5 posts (EN + ZH): fully on-chain SVG NFTs, the CRLF art trap, a Chainlink VRF v2.5 lottery, page-by-page PDF verification, JPA @Version
Five posts with custom OG + banner and hire CTAs. Three are web3, giving
that category its first posts — /categories/web3/ previously 404'd while the
index advertised it as '0 posts - coming soon'.

Backdated where the work genuinely is older: the foundry-nft and
hardhat-smartcontract-lottery commits date to 2024-08-15..19, so those two
posts fill the empty 2024-10 and 2024-12 archive months with updatedDate
holding the real date. The two 2026-08-19 posts use their real work date.

TERMINALS/BANNERS entries for all five slugs are committed this time (both
generators were clean; diffs verified purely additive).
2026-09-29 02:41:58 +08:00

7.7 KiB
Raw Blame History

title, description, pubDate, category, tags, ogImage, banner, draft
title description pubDate category tags ogImage banner draft
为什么在 Windows 上克隆后,我的链上 NFT 图像变了 为什么我在 Windows 上克隆后链上 NFT 的图像变了:core.autocrlf 往 vm.readFile 读取的 SVG 里注入 CRLF,base64 编码随之改变,而一个 eol=lf 规则就修好了它。 2026-08-19 web3
foundry
solidity
svg
base64
git
windows
crlf
/og/why-my-on-chain-nft-art-changed-on-windows.png /banners/why-my-on-chain-nft-art-changed-on-windows.png false

一个完全链上的 NFT,其作品本该是永久的。图像不是某个随时可能被 下架的 URL —— 它是一段 base64 字符串,在部署时写入合约存储, 之后永远无法改变。所以这个问题几乎是自己冒出来的:这样的作品 怎么会变?而且还是静默地变?

在我这个例子里,答案是:作品从来就不是一串固定的字符。它取决于 部署脚本在执行部署的那台机器上恰好从磁盘读到的字节 —— 而在 Windows 上,Git 会在脚本看到那些字节之前,悄悄地改写它们。

这事发生在一个小小的 Foundry 学习项目里,做的是一枚「心情」 NFT:一个 ERC-721,作品可以在笑脸 SVG 和哭脸 SVG 之间切换, 两者都编码在合约自身中。图像 URI 在部署时由 img/ 目录里的源 文件拼出来,所以这些文件的逐字节内容就是作品本身。下面就是一条 坏掉的换行符,如何差点让这幅作品变成「因机器而异」。

我遇到的事:部署脚本编码的是「读到的字节」

部署脚本用几行就干完了全部活:

string memory svgSmile = vm.readFile("img/smile.svg");
string memory svgSad = vm.readFile("img/sad.svg");
string memory imageUriSmile = svgToImageUri(svgSmile);
string memory imageUriSad = svgToImageUri(svgSad);

vm.readFile 返回一个字符串,Base64.encode 把这一模一样的 字节变成 data:image/svg+xml;base64,... URI,构造函数把两个 URI 永久存入存储。「链上」在这里是字面意思:部署机器上文件当时的 字节,如今就是合约数据 —— 永久地。部署时 SVG 哪怕差一个字节, 就是另一幅作品,在合约存续期内焊死不动。

我担心的这个差异来自 Git 的 core.autocrlf。Windows 上的 Git 安装通常会把人工作区里的文本文件改写成 CRLF 换行,即使仓库里存 的是 LF。SVG 是文本文件。CRLF 和 LF 是不同的字节,而 base64 对 不同字节的编码也不同。两行命令就能证明:

printf 'a\nb' | base64    # YQpi
printf 'a\r\nb' | base64  # YQ0KYg==

一个回车符,就改变了编码后的载荷。这类失败最讨厌的地方在于: 没有任何东西会告诉你。SVG 在任何一个编辑器里看起来都一模一样。 git status 依然干净,因为 Git 在比较文本时先做了换行归一化。 Foundry 也不在意 —— 它不解析 SVG,只是编码字节 —— 所以在任何 机器上都没有报错、没有警告。写进合约的作品,就这样静默地取决于 执行部署的是哪台机器。

修法:.gitattributes 里的一条规则

修法是一个文件、一条规则,而那段注释同样重要:

# Force LF line endings for asset files read by forge scripts (vm.readFile)
# so the working tree always matches what's stored in git, regardless of
# core.autocrlf / Windows checkout behavior.
img/*.svg text eol=lf

为什么有效:text 告诉 Git 把这些文件当作文本并做归一化,所以 仓库里它们永远以 LF 存储。eol=lf 则把这些人路径的工作区检出 钉死在 LF 上,覆盖任何机器上的 core.autocrlf 设置。两个属性 合在一起意味着:在一台 core.autocrlf=true 的 Windows 机器上, img/*.svg 依然以 LF 检出 —— 所以 vm.readFile 永远返回作者 提交时的那组字节,base64 URI 跨平台可复现。

说精确一点:这条规则管的是这些路径的检出,以及文件加入仓库时的 归一化。它并没有改写源 SVG —— 那些文件本来就是以 LF 提交的, 规则也不碰 blob 内容。它阻止的是规则落地之后、每一次检出时可能 发生的分歧。

同一个 commit 还修了第二件静默出错的东西:foundry.toml 里 remappings 的一个拼写错误。这条映射决定了 @openzeppelin/contracts/... 的导入如何解析到子模块,所以一个 错字就会让构建失败,而报错和我写的代码毫无关系:

remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts"]

紧挨着的两个坑

坑一:没有 fs_permissions,vm.readFile 拒绝执行

vm.readFile 是一个 fs cheatcode —— 除非 在 foundry.toml 里显式授权路径,否则 Foundry 不允许脚本触碰文件系统:

fs_permissions = [
    { access = "read", path = "./img/" },
    { access = "read", path = "./broadcast" },
]

对 ./img/ 的读权限是为了 SVG;./broadcast 是为了让铸造脚本 里的 DevOpsTools 助手能找到最近的部署日志。没有授权,部署会在 第一次读取时就失败 —— 这又是一种近乎静默的失败,因为报错指向 cheatcode,而不是你的代码。

坑二:DevOpsTools 需要 ffi = true

交互脚本会导入 foundry-devops 里的 DevOpsTools 来定位上一次 部署,而不是硬编码一个地址。这个导入需要启用 Foundry 的 ffi cheatcode,所以配置里带着它,并附了一句说明注释:

ffi = true # For use of DevOpsTools import from lib/foundry-devops/src/DevOopsTools.sol

ffi 是货真价实的权限授予 —— 它让脚本可以运行任意 shell 命令 —— 所以那句注释是应得的;在开启它之前,值得先弄清楚究竟是哪个 导入需要它。

如果重来,我会怎么做

两个习惯能更早抓住这个问题,也能抓住下一条换行符回归:

  1. 在测试里断言编码后的字节。 集成测试已经会跑真正的部署 脚本,框架是现成的。加一个单元测试,断言 vm.readFile("img/smile.svg") —— 或者最终的图像 URI —— 等于 预期的 LF 编码 base64 字符串,那么 CRLF 回归就会让 forge test 大声失败,而不是悄悄把另一幅作品送上链。
  2. 凡是脚本按字节读取的资源目录,都钉上 eol=lf。 这个坑 不只属于 SVG。如果脚本要内嵌 JSON 元数据或任何其他文本资源, 同样的事情照样发生。经验法则:任何用 fs cheatcode 读的东西, 在脚本提交之前,先给它配一条 .gitattributes 规则。

结果

修复之后,base64 载荷在 Windows 检出和 Linux 检出上完全一致 —— 同一串字符串,在两台机器上各自算一遍,逐字节相等 —— 所以部署 出来的合约作品是可复现的,而不是因机器而异的。这正是链上作品的 全部意义所在;而它差一点就被一个文件、一条规则、一次 printf 就能证明的问题悄悄毁掉。

坦白说清范围:这是一个学习项目 —— 小而注释详尽的合约与部署 脚本,跑在本地 Anvil 节点和 Sepolia 测试网上,不是生产代码。 但「在我机器上是好的」本身就是一条 bug 报告,而这个修法背后 的纪律,正是生产部署需要的:精确知道你的工具链在发什么字节。

我平时做全栈网站开发 —— 前端、后端、自托管部署 —— 而这一类 字节级、跨平台的排障,正是真正上线软件时会遇到的事。如果你的 项目需要一个不只盯 diff、还盯着字节的开发者,跟我说说: WhatsApp · [email protected] · hoelee.com。