补丁改了你根本没碰的字节:Hermes 文件写入的两处静默损坏

需求小得不能再小:把某个解析器文件里一个变量改个名。那个文件从你入职之前就是 ISO-8859-1 编码。Agent 说改好了,diff 看起来只有一小块,你打开文件——编辑器里 é 变成了 é,而且出现在你根本没提到的那些行上。git diff --stat 显示改动的行数远超你的要求。没有报错、没有警告,工具自己的复核还说补丁已生效。这篇讲的就是两条会丢掉“本来没碰的字节”的写入路径,以及为什么那张安全网当时还点了头。
写文件的精度,取决于读文件的精度
想清楚文件工具的一个前提就不难理解:要打补丁,工具必须先读文件,而读的过程对字节做了什么,写回去就是什么。而这两条路径上的读,都是有损的。
默认补丁模式走的是“文本通道”。 mode="replace"(默认模式)以及 V4A 编辑模式在前 1000 字节二进制采样之后的读取,都是用 errors="replace" 解码的。这个解码器适合用来看:UTF-8 解不出来的字节统一变成替换字符 U+FFFD。给人看日志,这是好意;可当它喂给写入时,就成了一次静默改写——你压根没碰的那些行上,每个无法解码的字节回来都成了 EF BF BD。在那个 Latin-1 文件上打一行补丁,文件里每一个 0xE9 都被重写了。
V4A 的读取还在做一项已经不需要的清理。 原始读取路径会把整个文件过一遍“终端围栏泄漏清理”,它会删掉 OSC 转义序列和 BEL 字节,还会丢弃任何包含 __HERMES_FENCE_ 字面量的行。那个包裹格式是为了兼容一个后来被替换掉的后端层,现在没有任何东西会输出它——也就是说,这项清理能删掉的只可能是你文件自己的字节:测试数据里的控制字符、一段抓包、一个老协议文件。
为什么没人发现
这个工具有复核步骤,给你看的 diff 也算一道。可两道复核读回文件时走的都是刚刚把它弄坏的那条有损通道。坏掉的字节读回来还是同样的替换字符,比对通过,工具报告:补丁已干净落盘。
“读的时候改写、再用同一次读来复核”——这个组合值得记在项目之外。复核如果和被复核的操作共用同一个变换,它就只能证明损坏是稳定的,证明不了数据是对的。 所以这个 bug 从来没有过错误提示,它的症状只出现在事后:评审在 git diff 里看到乱码,或者 CI 突然因为一个编码被悄悄改掉的文件而失败。
修复做了什么
PR #120515 让两种补丁模式都做到“读到的就是写回去的”。本地 POSIX 主机上,普通文件走原生读取(FIFO 仍然走 shell 路径,保留超时);远程后端则走 base64。内容穿过文本层时用 surrogateescape——这个编解码器能把任何无法解码的字节无损地往返,正好是 write_file 早就用于同一目的的编码的逆运算,并且有往返测试兜底。
有个细节能解释他们为什么没有简单地“把非 UTF-8 当成读取错误”:V4A 的 Add File、Delete File 以及 Move File 的两端,都用读取失败来做“文件是否存在”的判断。把编码特殊的文件变成读取错误,会让明明存在的文件被报成不存在。surrogateescape 让这类文件既能被读,又能逐字节保持原样。
兄弟 bug:写入把文件清空了
第二个修复 PR #122012 更直接:在 heredoc-stdin 那几个后端上——Modal、Daytona、Vercel——write_file 和 patch 不是把目标文件改坏,而是把它变成了一个空文件。
原因是个 shell 细节:heredoc 只绑到了包裹脚本的最后一条命令(trap - EXIT),本该接收内容的 cat > "$tmp" 什么都没读到,于是那个空临时文件被移到了目标位置。修复把 heredoc 重定向到整条复合命令,并补上了缺的另一半:内容要以逐字节精确的方式送达——去掉 heredoc 自带的结尾换行,这样写入校验才能通过。
随后的补充提交是一堂 shell 可移植性的小课:内容重新落地时只用 shell 内建命令,刻意避开 base64/head,因为少一个二进制就可能又悄悄把空输入交给命令;而 || : 是必须的——在继承 set -e 的情况下,read 遇到文件结束的返回状态会杀掉读取进程、给出空输入(在 bash 3.2 和 5.2 上都实测过)。一个“隔山打牛把文件清空”的 bug,修好之后关键路径上没有多出任何外部依赖。
你该做什么
- 打补丁前先确认编码。
file -I 路径一秒钟就能告诉你是 UTF-8 还是老的单字节编码。只要不是 UTF-8,就值得做下面这些检查。 - 用字节来验证,别用工具的话来验证。
git diff --stat看行数变化(而不是“看着还行”)、cmp跟一份已知正确的副本比、或者对你没碰的那一段算哈希。对二进制边缘的文件——测试数据、抓包、厂商二进制——哈希是唯一诚实的检查。 - 来自同一个工具的“写后验证”要打问号。 写文件的工具同时告诉你文件没问题,那是在陈述它自己的读路径,不是在陈述你的字节。
- 回头看看最近几周写到沙箱后端的文件。 零字节文件也是“一个文件”;如果某个配置、lockfile 或测试数据莫名是空的,这可能就是一种解释。空文件比乱码更容易发现——前提是你去看。
- 两个修复目前都只在
main上。 它们于 2026-09-25 合并,比 v0.21.5 打标签晚一天,所以不在那个 tag 里(它包含什么可以看 v0.21.5 发布页)。下一个版本或跟踪main的hermes update会带上它们。如果你现在就在用托管或沙箱路径,建议先养成写入后看一眼文件大小的习惯。
编码是这个项目近期修复里的常见主题:Windows 区域设置修复一文讲了另一半——那次崩溃来自“解码”而不是“写入”。而“我的数据到底是否完好”这个更大的问题,在状态数据库损坏笔记里有一套更完整的处理:同样的规则,不同的存储层。
结论
这里真正值得注意的不是“文件被改坏了”,而是复核和被复核对象共享了同一个缺陷。一个检查如果走了和它检查的操作相同的变换、编解码或传输,那它只能确认一致性,永远确认不了正确性。当你要搭一条“读—改—写”别人数据的流水线时,至少留一个完全不碰那个变换的独立检查:字节数、哈希、文件大小,或者写之前先复制一份。在这个案例里,这个独立检查正是修复重新带来的能力,也是下一次同类静默损坏会以“一条 diff”而不是“一个惊喜”出现的原因。