test(fs): compare Windows DACL access policy
ReplaceFileW preserves ACLs by merging security information, which may reserialize auto-inheritance state and duplicate equivalent ACEs. Compare the final ordered, de-duplicated ACE policy instead of requiring byte-identical self-relative descriptor buffers. Update the host-independent binding assertion to expect the namespaced absolute paths that the Win32 boundary actually receives, and align the package and bilingual RFC contracts with the semantic DACL guarantee.
This commit is contained in:
@@ -16,7 +16,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
|
||||
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
|
||||
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original descriptor survives ([Windows DACL preservation RFC](../../../docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation RFC](../../../docs/rfc/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
|
||||
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
|
||||
|
||||
The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
|
||||
|
||||
@@ -372,6 +372,28 @@ describe('streamWholeText', () => {
|
||||
// bits, so mode assertions are POSIX-only; native DACL preservation is asserted separately.
|
||||
const posixModes = process.platform !== 'win32'
|
||||
|
||||
function daclAcePolicy(descriptor: Buffer): string[] {
|
||||
const daclOffset = descriptor.readUInt32LE(16)
|
||||
if (daclOffset === 0) return []
|
||||
const aceCount = descriptor.readUInt16LE(daclOffset + 4)
|
||||
const policy: string[] = []
|
||||
const seen = new Set<string>()
|
||||
let offset = daclOffset + 8
|
||||
for (let index = 0; index < aceCount; index++) {
|
||||
const size = descriptor.readUInt16LE(offset + 2)
|
||||
const ace = Buffer.from(descriptor.subarray(offset, offset + size))
|
||||
// INHERITED_ACE records provenance, not the entry's access policy.
|
||||
ace.writeUInt8(ace.readUInt8(1) & ~0x10, 1)
|
||||
const key = ace.toString('hex')
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
policy.push(key)
|
||||
}
|
||||
offset += size
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
describe('writeFileAtomic — temp-file safety', () => {
|
||||
it('writes through a private staging dir and owner-only temp file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
@@ -409,7 +431,7 @@ describe('writeFileAtomic — temp-file safety', () => {
|
||||
})
|
||||
|
||||
expect(await readFile(file, 'utf8')).toBe('new')
|
||||
expect(await readFileDaclWin32(file)).toEqual(expectedDacl)
|
||||
expect(daclAcePolicy(await readFileDaclWin32(file))).toEqual(daclAcePolicy(expectedDacl))
|
||||
})
|
||||
|
||||
it('copies a Windows target DACL before content and publishes through secure replacement', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Host-independent binding tests for the Win32 DACL and replacement helpers. */
|
||||
|
||||
import { toNamespacedPath } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type GetFileSecurityW = (
|
||||
@@ -92,7 +93,7 @@ describe('Windows file-security helpers', () => {
|
||||
await copyFileDaclWin32('source', 'temp')
|
||||
expect(native.installed).toEqual([descriptor])
|
||||
await replaceFileWin32('target', 'temp')
|
||||
expect(native.replacements).toEqual([['target', 'temp']])
|
||||
expect(native.replacements).toEqual([[toNamespacedPath('target'), toNamespacedPath('temp')]])
|
||||
})
|
||||
|
||||
it('maps descriptor-size probe failures to Node-style codes', async () => {
|
||||
|
||||
Reference in New Issue
Block a user