fix(fs): preserve Windows DACLs across atomic replacement

Copy an existing target's DACL onto the empty staging file before any content is written, then publish with ReplaceFileW so Windows replacement keeps the target security descriptor instead of inheriting the broader parent policy.

Keep new-file inheritance and POSIX mode behavior unchanged, retain the already-protected temp when a concurrently removed target requires rename fallback, and translate native errors into Node-style codes for the filesystem error boundary.

Add host-independent Win32 binding coverage, native Windows descriptor assertions, package documentation, and a bilingual implemented RFC that supersedes the earlier inheritance-only replacement claim.
This commit is contained in:
Tianyi Cui
2026-07-19 12:38:18 +08:00
parent 46580e4083
commit 2b673bd68d
12 changed files with 472 additions and 16 deletions

View File

@@ -89,6 +89,12 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 |
| [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 |
### Bug-fix
| Title | First proposed |
|---|---|
| [Preserve Windows DACLs during atomic file replacement](implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md) | 2026-07-19 |
### Simplification
| Title | First proposed |

View File

@@ -2,21 +2,23 @@
Status: implemented
The replacement-file decision in this record is superseded by [Windows DACL preservation](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md).
## Problem
`writeFileAtomic` in `@deepseek-ai/dsh-fs-local` protects write-in-progress content with POSIX mode bits: the staging directory is created `0o700`, the temp file is opened `0o600`, and new files default to `0o600`. On POSIX this keeps temporary content owner-only regardless of the parent directory's permissions.
Windows has no working equivalent behind the same API. Node's `chmod` there drives only the read-only attribute (every mode this package passes carries owner-write, so the calls are benign no-ops), and `stat().mode` reports synthetic `0o666`/`0o444` bits. The real security state is the file's DACL, which this code never sets; a newly created file or directory inherits its DACL from its parent directory.
Windows has no working equivalent behind the same API. Node's `chmod` there drives only the read-only attribute (every mode this package passes carries owner-write, so the calls are benign no-ops), and `stat().mode` reports synthetic `0o666`/`0o444` bits. The real security state is the file's DACL: a newly created file or directory inherits from its parent, while replacement needs the explicit handling owned by the superseding RFC.
## Decision
Production code is unchanged: no platform fork, no DACL management. The Windows privacy invariant is structural rather than mode-driven — the staging directory is created inside the target's parent directory (`dirname(absolutePath)`), so it and the temp file inherit exactly the destination directory's DACL, and write-in-progress content is never exposed more widely than the destination itself. In the typical deployment (a coding agent writing the user's own project tree under `C:\Users\<user>\`) the inherited DACL is owner + SYSTEM + Administrators, matching the POSIX intent.
New Windows files use directory inheritance rather than synthetic mode bits: the staging directory is created inside the target's parent directory (`dirname(absolutePath)`), so it and the temp file inherit the destination directory's DACL. Replacement files follow the stricter [DACL preservation contract](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md).
Tests assert mode bits on POSIX only. There is no Windows-side ACL assertion because there is no Windows-side code behavior to pin: an ACL check on a `mkdtemp(tmpdir())` fixture would verify Windows DACL inheritance plus the machine's `%TEMP%` ACL — the operating system, not this package — and no change to this package could turn it red.
Tests assert mode bits on POSIX only. Native Windows coverage pins the package-owned replacement behavior; new-file inheritance remains an operating-system contract rather than a machine-specific ACL allowlist.
## Alternatives considered
**Explicit protected DACLs.** Granting owner-only access would require per-write FFI or a subprocess, break inheritance, and surprise users whose project directories are deliberately shared. This becomes appropriate only if the threat model includes hostile local readers of broadly accessible target directories.
**Explicit owner-only DACLs for new files.** Rejected because they would break inheritance and surprise users whose project directories are deliberately shared. Replacement writes copy the target's existing DACL rather than inventing an owner-only policy.
**Test-side ACL verification.** A `Get-Acl` SID allowlist or `icacls` would verify Windows inheritance and the machine's `%TEMP%` ACL rather than package behavior; `icacls` also localizes well-known account names, making parsing locale-fragile.
@@ -24,6 +26,6 @@ Tests assert mode bits on POSIX only. There is no Windows-side ACL assertion bec
## Consequences
POSIX keeps the stronger guarantee: owner-only temp content regardless of the parent directory. Windows guarantees only "no wider than the destination": a target inside a broadly accessible directory (a share, a permissive `D:\` root) gets equally accessible write-in-progress content. The gap is deliberate and documented, not an oversight.
POSIX keeps owner-only temp content regardless of the parent directory. A new Windows target inside a broadly accessible directory inherits that accessibility by design; a replacement retains the target's narrower DACL when one exists.
Mode preservation across a replace degenerates to a no-op on Windows: a writable file probes as `0o666`, and replaying that through `chmod` leaves the read-only attribute clear. A read-only target cannot be replaced at all there — `rename` over it fails before the preserved mode would matter.
Mode preservation across a replace degenerates to a no-op on Windows: a writable file probes as `0o666`, and replaying that through `chmod` leaves the read-only attribute clear. A read-only target cannot be replaced there because publication fails before the synthetic mode would matter.

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-19-windows-atomic-write-dacl-preservation.md: 393ce8a992b8c0b7b580f2c794e098d66e14258e
2026-07-19-windows-atomic-write-dacl-preservation.zh.md: c7a0b6278cf739cc5ef4432d679e48b88b61d198

View File

@@ -0,0 +1,27 @@
# RFC: Preserve Windows DACLs during atomic file replacement
Status: implemented
English | [中文](2026-07-19-windows-atomic-write-dacl-preservation.zh.md)
## Problem
On Windows, creating the staging directory and temp file under the target's parent and relying only on inherited DACLs 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 security descriptor and other replacement metadata. New files have no prior descriptor to preserve and continue to inherit the destination directory's DACL.
Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement descriptor. Host-independent binding tests cover Win32 error translation and every native call boundary.
## 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.
## 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. New-file behavior remains directory-inherited, and POSIX mode behavior is unchanged.

View File

@@ -0,0 +1,27 @@
# RFC: Windows 原子文件替换期间保留 DACL
Status: implemented
[English](2026-07-19-windows-atomic-write-dacl-preservation.md) | 中文
## 问题
在 Windows 上,在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL足以满足新建文件的需要但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。
## 决策
`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL在写入内容前将其以禁止继承的形式应用到空临时文件并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的安全描述符及其他替换元数据。新建文件没有既有描述符需要保留,因此仍继承目标目录的 DACL。
Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件并对比最终替换文件的描述符。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。
## 备选方案
**替换文件时依赖目录继承。** 不予采用,因为目标文件可能带有比父目录更严格的显式或受保护 DACL目录继承既无法保护暂存内容也无法保留目标文件的访问策略。
**使用 `ReplaceFileW`,但不保护临时文件。** 不予采用,因为这只能在内容已经按暂存文件继承的 DACL 写入之后修复最终描述符。
**每次写入都设置仅所有者可访问的 DACL。** 不予采用,因为这会破坏项目有意设置的共享权限。复制目标文件的 DACL 可以保留部署中已有的访问策略,无需另行创设策略。
## 影响
替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL如果权限不足系统会在写入内容前明确失败。该包package引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新建文件仍按目录继承POSIX mode 行为保持不变。