DSH / Atlas
2026-07-30implementedbug-fix

Bound overwrite contextual-diff bases at the provider

在提供方限制覆写上下文 diff 基础

`dsh-fs-local` returned the complete prior file in `FsWriteOutcome.before` so consumers could build a contextual overwrite diff. That presentation-only pre-read was unbounded: a large overwrite could allocate the entire prior file, and checking an earlier path stat alone could not enforce a limit because an external process could replace or grow the file between the stat and the read. A large replacement also made th

English

Problem

dsh-fs-local returned the complete prior file in FsWriteOutcome.before so consumers could build a contextual overwrite diff. That presentation-only pre-read was unbounded: a large overwrite could allocate the entire prior file, and checking an earlier path stat alone could not enforce a limit because an external process could replace or grow the file between the stat and the read. A large replacement also made the contextual hunk approach the replacement size even when the prior file was small. This closes the deferred bound recorded by result-time applied-hunk diffs.

Decision

LocalFileSystem.Config.diffBasisMaxBytes is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies before only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns null. A size change after descriptor stat also returns null, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns null, as does any descriptor-phase errno — a prior file deleted or made unreadable between the caller's preflight and the basis open cannot fail a write the caller already committed to; only cancellation and non-errno faults propagate. These outcomes do not block the atomic write.

The local provider owns this decision because before is its optional, best-effort basis: it can avoid acquiring prior content that the configured pair limit has already made ineligible. tool-fs continues to own diff computation, retention, and presentation. The setting is independent of tool-fs.readStreamMinSize; read routing and overwrite presentation are different policies and need not share a value.

before: null asks consumers to use their existing whole-file fallback. The limit bounds only the extra prior-content acquisition and eligibility for a contextual pair. It does not bound the caller-owned replacement, the returned after value, or a consumer's fallback rendering.

Alternatives considered

Keep a hardcoded threshold equal to the read tool's streaming threshold. Rejected because the read threshold is deployment-configurable and consumer-owned. Two same-valued constants would create an unenforced cross-package coupling, while the overwrite basis is itself a deployment memory/presentation choice.

Gate only the prior side in the provider and cap new-content diffing in tool-fs. Rejected because it would acquire prior text even when the provider's configured pair limit already excludes the replacement, and it would split one before eligibility rule across two plugins. Consumers remain free to impose additional output limits.

Trust the initial probe() size before using an ordinary whole-file read. Rejected because that size can become stale before the read. The descriptor reader must enforce the bound on the object it actually reads.

Stream a contextual diff for arbitrarily large pairs. Rejected for this bug fix because the current filesystem seam returns complete before/after strings and the current diff implementation consumes them. A streaming diff would require a separate cross-package protocol and presentation design.

Consequences

Deployments can tune the extra overwrite-basis cost without changing read routing. At or above the exclusive limit, overwrites still succeed and remain visible through the whole-file fallback, but lose contextual hunks. Below the limit, the provider can still hold almost diffBasisMaxBytes of prior text in addition to the caller's replacement. The bounded descriptor read adds an open/stat/read sequence for eligible overwrites, while preventing a stale path probe from turning that sequence into an unbounded allocation.

中文

问题

dsh-fs-local 会在 FsWriteOutcome.before 中返回完整旧文件,供消费方生成覆写上下文 diff。这个仅用于展示的预读没有上限:大文件覆写可能分配整个旧文件;而仅检查较早的路径 stat 也无法真正实施上限,因为外部进程可以在 stat 与读取之间替换文件或扩大文件。即使旧文件很小,大替换内容也会使上下文 hunk 接近替换内容本身的大小。本改动关闭了 result-time applied-hunk diff 中记录的暂缓上限事项。

决策

LocalFileSystem.Config.diffBasisMaxBytes 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 before。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 null。描述符 stat 后发生大小变化时同样返回 null,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 null;描述符阶段的任何 errno 同样如此——旧文件在调用方预检之后、基础读取打开之前被删除或变得不可读,不能让调用方已经提交的写入失败;只有取消和非 errno 故障会继续向上传播。这些结果都不会阻止原子写入。

本地提供方拥有该决策,因为 before 是它提供的可选、尽力而为的基础:当配置的成对上限已使替换内容不合格时,它可以避免获取旧内容。tool-fs 继续拥有 diff 计算、保留与展示。该配置独立于 tool-fs.readStreamMinSize;读取路由与覆写展示是不同策略,无需共享数值。

before: null 要求消费方使用既有的整文件回退。该上限只限制额外获取旧内容的成本,以及上下文内容对是否合格;它不限制调用方持有的替换内容、返回的 after 值或消费方的回退渲染。

考虑过的替代方案

保留一个与读取工具流式阈值相等的硬编码阈值。 否决,因为读取阈值可由部署配置,且归消费方所有。两个同值常量会形成无法强制的一致性耦合,而覆写基础本身也是部署层面的内存与展示选择。

提供方只限制旧内容一侧,并在 tool-fs 中限制新内容 diff。 否决,因为当提供方配置的成对上限已经排除替换内容时,这仍会获取旧文本;同时会把同一条 before 合格规则拆到两个插件中。消费方仍可自由施加额外的输出限制。

信任初次 probe() 的大小,再执行普通整文件读取。 否决,因为该大小可能在读取前变旧;描述符读取必须对它真正读取的对象实施上限。

为任意大的内容对流式生成上下文 diff。 本次缺陷修复不采用,因为当前文件系统 seam 返回完整的 before/after 字符串,当前 diff 实现也消费这两个字符串。流式 diff 需要独立的跨包协议与展示设计。

后果

部署可以调整额外的覆写基础成本,而不改变读取路由。达到或超过排他上限时,覆写仍会成功,并通过整文件回退保持可见,但不再提供上下文 hunk。低于上限时,除调用方的替换内容外,提供方仍可能持有接近 diffBasisMaxBytes 的旧文本。对于合格覆写,有上限的描述符读取会增加一次 open/stat/read 序列,同时防止陈旧路径探测把该序列变成无上限分配。