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:
@@ -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. */
|
||||
|
||||
134
packages/fs/fs-local/src/win32.ts
Normal file
134
packages/fs/fs-local/src/win32.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Windows security-descriptor helpers for atomic local-file replacement. Koffi loads lazily so
|
||||
* non-Windows processes never open Win32 libraries.
|
||||
* @module @deepseek-ai/dsh-fs-local/win32
|
||||
*/
|
||||
|
||||
import { toNamespacedPath } from 'node:path'
|
||||
|
||||
type GetFileSecurityW = (
|
||||
path: string,
|
||||
requestedInformation: number,
|
||||
descriptor: Buffer | null,
|
||||
length: number,
|
||||
needed: [number],
|
||||
) => number
|
||||
type SetFileSecurityW = (path: string, securityInformation: number, descriptor: Buffer) => number
|
||||
type ReplaceFileW = (
|
||||
replaced: string,
|
||||
replacement: string,
|
||||
backup: null,
|
||||
flags: number,
|
||||
exclude: null,
|
||||
reserved: null,
|
||||
) => number
|
||||
type GetLastError = () => number
|
||||
|
||||
interface Win32Bindings {
|
||||
getFileSecurityW: GetFileSecurityW
|
||||
setFileSecurityW: SetFileSecurityW
|
||||
replaceFileW: ReplaceFileW
|
||||
getLastError: GetLastError
|
||||
}
|
||||
|
||||
interface Win32ErrnoException extends NodeJS.ErrnoException {
|
||||
win32Code: number
|
||||
}
|
||||
|
||||
const DACL_SECURITY_INFORMATION = 0x00000004
|
||||
const PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000
|
||||
const ERROR_FILE_NOT_FOUND = 2
|
||||
const ERROR_PATH_NOT_FOUND = 3
|
||||
const ERROR_ACCESS_DENIED = 5
|
||||
|
||||
let bindings: Win32Bindings | undefined
|
||||
|
||||
async function win32(): Promise<Win32Bindings> {
|
||||
if (bindings !== undefined) return bindings
|
||||
const koffi = (await import('koffi')).default
|
||||
const advapi32 = koffi.load('advapi32.dll')
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
bindings = {
|
||||
getFileSecurityW: advapi32.func('int __stdcall GetFileSecurityW(const char16_t *path, uint32_t requested, void *descriptor, uint32_t length, _Out_ uint32_t *needed)') as GetFileSecurityW,
|
||||
setFileSecurityW: advapi32.func('int __stdcall SetFileSecurityW(const char16_t *path, uint32_t information, const void *descriptor)') as SetFileSecurityW,
|
||||
replaceFileW: kernel32.func('int __stdcall ReplaceFileW(const char16_t *replaced, const char16_t *replacement, const char16_t *backup, uint32_t flags, void *exclude, void *reserved)') as ReplaceFileW,
|
||||
getLastError: kernel32.func('uint32_t __stdcall GetLastError()') as GetLastError,
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
function errnoCode(win32Code: number): string {
|
||||
switch (win32Code) {
|
||||
case ERROR_FILE_NOT_FOUND:
|
||||
case ERROR_PATH_NOT_FOUND:
|
||||
return 'ENOENT'
|
||||
case ERROR_ACCESS_DENIED:
|
||||
return 'EACCES'
|
||||
default:
|
||||
return 'EIO'
|
||||
}
|
||||
}
|
||||
|
||||
function win32Error(syscall: string, win32Code: number, path: string): Win32ErrnoException {
|
||||
const code = errnoCode(win32Code)
|
||||
const error = new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path}`) as Win32ErrnoException
|
||||
error.code = code
|
||||
error.errno = win32Code
|
||||
error.syscall = syscall
|
||||
error.path = path
|
||||
error.win32Code = win32Code
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file's self-relative DACL security descriptor.
|
||||
* @param path - existing file whose DACL is read.
|
||||
* @returns a descriptor buffer accepted by `SetFileSecurityW`.
|
||||
*/
|
||||
export async function readFileDaclWin32(path: string): Promise<Buffer> {
|
||||
const api = await win32()
|
||||
const nativePath = toNamespacedPath(path)
|
||||
const needed: [number] = [0]
|
||||
api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, null, 0, needed)
|
||||
if (needed[0] === 0) throw win32Error('GetFileSecurityW', api.getLastError(), path)
|
||||
|
||||
const descriptor = Buffer.alloc(needed[0])
|
||||
if (api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, descriptor, descriptor.length, needed) === 0) {
|
||||
throw win32Error('GetFileSecurityW', api.getLastError(), path)
|
||||
}
|
||||
return descriptor.subarray(0, needed[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an existing file's DACL onto another file and protect it from staging-parent inheritance.
|
||||
* The destination must still be empty when confidentiality depends on this call.
|
||||
* @param source - existing file whose DACL is copied.
|
||||
* @param destination - existing file that receives the protected DACL.
|
||||
*/
|
||||
export async function copyFileDaclWin32(source: string, destination: string): Promise<void> {
|
||||
const descriptor = await readFileDaclWin32(source)
|
||||
const api = await win32()
|
||||
const information = (DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION) >>> 0
|
||||
if (api.setFileSecurityW(toNamespacedPath(destination), information, descriptor) === 0) {
|
||||
throw win32Error('SetFileSecurityW', api.getLastError(), destination)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a Windows file while preserving the replaced file's ACL and other replace metadata.
|
||||
* @param replaced - existing destination file.
|
||||
* @param replacement - closed staging file on the same volume.
|
||||
*/
|
||||
export async function replaceFileWin32(replaced: string, replacement: string): Promise<void> {
|
||||
const api = await win32()
|
||||
if (api.replaceFileW(
|
||||
toNamespacedPath(replaced),
|
||||
toNamespacedPath(replacement),
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
) === 0) {
|
||||
throw win32Error('ReplaceFileW', api.getLastError(), replaced)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user