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

@@ -12,6 +12,7 @@ import type { BigIntStats, Dirent, Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts'
const BINARY_SAMPLE_BYTES = 8192
@@ -74,10 +75,16 @@ function versionOf(info: BigIntStats): FsVersion {
* file before it is renamed over the target.
*/
export interface FsIoInternals {
/** Override the host platform for native-publication unit coverage. */
platform?: NodeJS.Platform
/** Override the generated private staging-dir name (relative to the target dir). */
tempDirName?: (writePath: string) => string
/** Override the generated temp-file name (relative to the private staging dir). */
tempName?: (writePath: string) => string
/** Override the Win32 DACL copy boundary. */
copyFileDacl?: (source: string, destination: string) => Promise<void>
/** Override the Win32 security-preserving replacement boundary. */
replaceFile?: (replaced: string, replacement: string) => Promise<void>
/** Test hook after the temp file is written/synced but before final chmod+rename. */
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
}
@@ -412,11 +419,13 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
/**
* Atomically replace a file through a private, synced staging file in the same directory.
* POSIX protects the staging directory and file with `0o700` and `0o600`; Windows
* inherits the destination directory's DACL because Node mode bits are synthetic there.
* POSIX protects the staging directory and file with `0o700` and `0o600`. A new Windows file
* inherits the destination directory's DACL; a replacement copies the existing target's DACL
* onto the empty temp before writing and preserves the target descriptor at publication.
* @param absolutePath - destination; missing parent directories are created.
* @param content - the full UTF-8 text to write.
* @param mode - final POSIX mode, or `0o600` when omitted; inert on Windows.
* @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file;
* inert as a mode on Windows but identifies replacement security semantics.
* @param signal - cancellation checked before the final rename.
* @param internals - test seam for pinning temp names and observing the staged file.
*/
@@ -436,6 +445,9 @@ export async function writeFileAtomic(
const stagingDir = join(directory, stagingDirName)
const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`
const tempPath = join(stagingDir, tempName)
const platform = internals.platform ?? process.platform
const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32
const replaceFile = internals.replaceFile ?? replaceFileWin32
let handle: Awaited<ReturnType<typeof open>> | undefined
let stagingCreated = false
try {
@@ -445,6 +457,9 @@ export async function writeFileAtomic(
handle = await open(tempPath, 'wx', 0o600)
await handle.chmod(0o600)
if (platform === 'win32' && mode !== undefined) {
await copyFileDacl(absolutePath, tempPath)
}
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
await handle.sync()
await internals.inspectTemp?.({ stagingDir, tempPath })
@@ -453,7 +468,18 @@ export async function writeFileAtomic(
handle = undefined
throwIfAborted(signal, 'write')
await rename(tempPath, absolutePath)
if (platform === 'win32' && mode !== undefined) {
try {
await replaceFile(absolutePath, tempPath)
} catch (error: unknown) {
// Preserve the old behavior when an external actor removes the observed target during
// staging: the temp already carries that target's protected DACL, so rename recreates it.
if (!isENOENT(error)) throw error
await rename(tempPath, absolutePath)
}
} else {
await rename(tempPath, absolutePath)
}
await rm(stagingDir, { recursive: true, force: true })
} catch (error: unknown) {
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */