Repository navigation
[Bug] 版本 token 包含 ctime 导致元数据未变、内容未变时误报 FS_STALE_VERSION #3111
Replies: 2 comments
|
在当前 master 上核对了一下,versionOf 的实现和你贴的完全一致,dev:ino:size:mtimeNs:ctimeNs,在 packages/fs/fs-local/src/fsio.ts 第 74 到 75 行。 关于你问的第一个问题,ctimeNs 是不是故意加进去防止漏报的安全选择,我查了一下 git 历史。这行是在提交 a0e917f("Optimize workspace instruction change detection")里加进去的,提交信息和代码注释("high-resolution identity and freshness metadata")都没提到安全方面的考虑。看起来更像是那次提交为了另一个功能(workspace-context 的变更检测)改用 bigint stats,顺带复用了这个共享 helper,把 ctime 也带了进来,不太像是专门为了防止伪造 mtime 才加的。所以我的判断是这更偏向"还没考虑到"而不是"刻意的安全取舍",不过最终还是要维护者来确认。 不过在去掉 ctime 之前,有一点我觉得值得摆出来。mtime 是任何有写权限的进程都能任意设置的,标准的 utime 系统调用不需要特殊权限就能把 mtime 改成任意值。ctime 不一样,POSIX 下没有办法直接设置 ctime,内核只要检测到元数据或内容变化就会自动更新它。所以现在这个版本 token 对"先改内容再把 mtime 改回原值"这种攻击是有一定抵抗力的,只用 mtime 就没有这层保护了。这是一个比较窄的威胁模型,日常场景下大概率没人会关心,但这是一个真实存在的差异,不只是文中说的 small risk 那么简单,值得维护者明确权衡一下再决定,而不是当作纯粹的收益来采纳。 另外 README 里已有的 Known Limitations 那条其实说的是反方向的问题,存储层没法更新 mtime/ctime 导致漏报,并不覆盖你这里说的元数据变了但内容没变这种误报场景。如果方案 1 或方案 2 落地,这确实算是一个新场景,值得单独补一条说明。 |
|
补充一份本机实测,供维护者判断这个问题的触发来源与影响面。 环境:macOS 26.6.2 (25G83)、DSH 0.2.1-rc.5、仓库位于 1. 只用 chmod 就能 100% 复现
2. 本机真实触发链 用 这也解释了两个现象:为什么看起来像“每次写完都必须重读”;为什么在 3. 验证方案 1 可行性的临时 workaround 我们按方案 1 的语义做了一个不修改 provider 的外部插件,用来验证“只对齐 ctime 段”的实际效果:https://github.com/wx-yss/dsh-fs-ctime-guard 。它只适合当临时方案,并且明确接受“同尺寸 + 精确恢复 mtime 的并发修改会被放过”的取舍(与方案 1 一致);长期还是建议在 provider / observation 层引入原内容基线(方案 2),或者至少把 token 收窄为 |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
问题概述
fs-local用于判断文件是否"陈旧"的版本 token,目前由以下字段拼接而成:其中
ctimeNs(inode 元数据最后修改时间)会被任何元数据变化刷新,例如chmod、chown、修改扩展属性、硬链接计数变化等,即使文件的实际文本内容一个字节都没有变化。这会导致一个和"文件内容是否真的变了"完全无关的假阳性:如果在模型
read之后、下一次write/edit之前,有任何进程(包括常见的编辑器保存行为、格式化工具、权限修复脚本等)touch 了这个文件的元数据但没有改内容,editText/writeText就会把它当成"文件已变化",返回FS_STALE_VERSION,即便重新读取后看到的内容和之前完全一致。可复现步骤
用一个通用的临时文件即可稳定复现,不需要任何并发:
模型
read该文件一次。在下一次
edit之前,执行一次只影响元数据、不改内容的操作,例如:可以验证:
mtime不变,ctime发生了变化,文件字节内容完全没变。紧接着对该文件发起一次
edit(替换一个明确存在的字面量)。该次
edit会返回:即使重新
read后可以看到内容与之前一致。我在本地按上述步骤复现了两次,均 100% 触发;作为对照,只执行
touch -a(仅修改 atime)不会触发,符合versionOf不包含atime的实现。和另外两个既有讨论的关系
这个问题和我此前提交的两个讨论都相关,但根因不同,希望单独讨论:
FS_STALE_VERSION,那是版本保护在正确工作,只是需要在模型提示里说清楚。FS_STALE_VERSION误报,属于版本 token 本身对元数据噪声过于敏感,和并发无关。关于修复方向:为什么建议排除
ctimeNs,而不是改成内容哈希一个直观的想法是把陈旧检测换成内容哈希,但我认为这会带来不必要的性能代价。下面按推荐程度列出几个具体方向,供讨论:
方案 1(推荐):版本 token 去掉
ctimeNs,只保留dev:ino:size:mtimeNsstat,不增加任何 I/O。rsync、make等主流工具的增量判断依据一致——它们普遍以mtime/size判断文件是否变化,通常不依赖ctime。方案 2:
editText增加"内容兜底校验",仅在 stat 版本不一致时触发即
stat版本一致时直接放行(保留现有快速路径);stat版本不一致时不要立刻报错,而是读一次当前内容,与read时留存的内容或哈希做比较,只有内容确实不同才报FS_STALE_VERSION。editText本来就要把整个文件读进内存做字面量替换(README 已注明这一点),这次额外读取的边际成本不大。fs-observation-policy的一次接口扩展(目前只存版本 token,不存内容)。edit,不建议套用到write——因为write当前的陈旧检测只是一次stat("0 stat" 的性能特征),如果也叠加内容读取,会让每次覆盖写入前都多一次完整读取,对大文件、连续多次覆盖同一大文件的场景会有实打实的性能回退。方案 3:保留
ctimeNs校验,但仅作为诊断信息而非拒绝依据即正式校验只用
dev:ino:size:mtimeNs(等同方案 1),但当ctimeNs不一致、其余字段一致时,在错误信息或调试日志里追加提示,例如"检测到文件元数据变化(如权限/属主),但内容判定未过期",帮助排查问题来源,同时不影响放行判断。方案 4(不推荐,仅作对比):全面切换为内容哈希
即把
write/edit的陈旧检测统一换成读取内容并计算哈希。editText而言代价不大(原因同方案 2)。writeText而言,会把当前"仅stat、不读内容"的路径变成"每次覆盖写入前都要完整读一次文件",在大文件或高频覆盖写场景下有明显的性能回退,性价比低于方案 1。建议的落地顺序
优先落地方案 1,这是收益成本比最高的改动,几乎没有下行风险。如果维护者认为"绝对不能漏掉任何并发修改"(哪怕只是元数据层面的),可以在方案 1 基础上,为
editText单独叠加方案 2 作为加强校验(因为edit反正要整读文件,兜底成本低),但不建议把这一加强校验同时套用到writeText上,以免引入方案 4 描述的性能回退。方案 3 可以作为方案 1 的诊断信息补充。想请教维护者
ctimeNs纳入陈旧检测,是刻意的安全选择(宁可误报也不漏报),还是尚未考虑到这种元数据噪声场景?dev:ino:size:mtimeNs(方案 1),作为对write/edit都零额外成本的修复?editText的可选兜底路径(方案 2),而不改变writeText当前"仅 stat、不读内容"的性能特征?fs-local的 README 中已经把"版本 token 依赖文件系统元数据"列为已知限制之一,本讨论算是对这条已知限制的一次具体场景补充和方向请教。All reactions