DSH / Atlas
2026-07-19implementedbug-fix

Preserve Windows DACLs during atomic file replacement

Windows 原子文件替换期间保留 DACL

Atomic writes protect POSIX staging directories with `0o700` and temp files with `0o600`, but Windows mode bits expose only a synthetic read-only view of the actual DACL. Creating staging under the target's parent and relying on inheritance is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL

English

Problem

Atomic writes protect POSIX staging directories with 0o700 and temp files with 0o600, but Windows mode bits expose only a synthetic read-only view of the actual DACL. Creating staging under the target's parent and relying on inheritance is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement.

Decision

dsh-fs-local reads an existing target's DACL with GetFileSecurityW, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with ReplaceFileW. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; ReplaceFileW preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New Windows files have no prior descriptor to preserve and continue to inherit the destination directory's DACL; their staging directory therefore lives beside the target. POSIX keeps the owner-only staging modes and preserves an existing target mode.

Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary. Mode-bit assertions remain POSIX-only; new-file DACL inheritance is an operating-system contract rather than a machine-specific account allowlist.

Alternatives considered

Rely on directory inheritance for replacements. Rejected because a target may carry a narrower explicit or protected DACL than its parent, so inheritance neither protects staged content nor preserves the target access policy.

Use ReplaceFileW without protecting the temp. Rejected because it repairs the final descriptor only after the content has already been written under the staging file's inherited DACL.

Install an owner-only DACL for every write. Rejected because it would discard deliberate project sharing. Copying the target DACL preserves the deployment's existing access policy instead of inventing one.

Assert inherited accounts with Get-Acl or icacls. Rejected because such a test verifies machine policy rather than package behavior, and localized well-known account names make the output unstable across hosts.

Skip the existing chmod calls on Windows. Rejected because Node maps these writable modes to benign no-ops; platform guards add branches without changing DACL behavior.

Consequences

Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. A new Windows file inherits broad directory access when the directory is broad by design, while POSIX temp content stays owner-only; a read-only Windows target still fails publication before synthetic mode replay could matter.

中文

问题

原子写入在 POSIX 上以 0o700 保护暂存目录、以 0o600 保护临时文件,但 Windows mode 位只呈现实际 DACL 的合成只读视图。在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。

决策

dsh-fs-local 通过 GetFileSecurityW 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 ReplaceFileW 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;ReplaceFileW 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定约定。新的 Windows 文件没有既有描述符需要保留,因此仍继承目标目录的 DACL;其暂存目录也因此位于目标文件旁。POSIX 继续使用仅所有者可访问的暂存 mode,并保留现有目标文件的 mode。

Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。mode 位断言仍仅适用于 POSIX;新文件的 DACL 继承由操作系统约定规定,不应通过特定机器的账户允许列表来断言。

备选方案

替换文件时依赖目录继承。 不予采用,因为目标文件可能带有比父目录更严格的显式或受保护 DACL;目录继承既无法保护暂存内容,也无法保留目标文件的访问策略。

使用 ReplaceFileW,但不保护临时文件。 不予采用,因为这只能在内容已经按暂存文件继承的 DACL 写入之后修复最终描述符。

每次写入都设置仅所有者可访问的 DACL。 不予采用,因为这会破坏项目有意设置的共享权限。复制目标文件的 DACL 可以保留部署中已有的访问策略,无需另行创设策略。

使用 Get-Aclicacls 断言继承账户。 不予采用,因为这类测试验证的是机器策略,而不是包行为;系统内置账户名会本地化,使输出在不同宿主上不稳定。

在 Windows 上跳过现有 chmod 调用。 不予采用,因为 Node 会把这些可写 mode 映射为无害的空操作;平台条件判断只会增加分支,不会改变 DACL 行为。

影响

替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新的 Windows 文件会在目录按设计开放较宽访问权限时继承该权限,而 POSIX 临时内容仍仅允许所有者访问;只读 Windows 目标文件仍会在发布时失败,早于重放合成 mode 可能产生影响的时点。