From 6275ecbcf60703c18962b81f3f201ddb1bee2a0a Mon Sep 17 00:00:00 2001 From: cervantesh <11169707+cervantesh@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:26:31 -0400 Subject: [PATCH 1/2] fix: detect concurrent destination changes --- CHANGELOG.md | 3 + README.md | 6 + dist/src/filesystem-client.d.ts | 2 + dist/src/filesystem-client.js | 179 +++++++++++++++++++++---- dist/test/filesystem-client.test.js | 83 +++++++++++- docs/ARCHITECTURE.md | 5 +- docs/OPERATIONS.md | 5 +- src/filesystem-client.ts | 197 ++++++++++++++++++++++++---- test/filesystem-client.test.ts | 98 ++++++++++++++ 9 files changed, 530 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d5dac..755241b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,16 +10,19 @@ - Linux, Windows, macOS, Node 22, and Node 24 CI coverage; - package provenance/license verification and immutable GitHub release artifact creation; - weekly Dependabot and CodeQL scanning workflows. +- deterministic filesystem race fixtures for changed content, replacement identity, deletion, and missing-to-created destinations. ### Changed - filesystem edits now classify paths before decoding and use same-directory atomic replacement with mode, BOM, newline, and cleanup guarantees; - package contents exclude compiled tests and include complete repository provenance metadata. +- filesystem atomic replacement now performs best-effort optimistic destination revalidation with identity and SHA-256 byte-digest evidence, returning `[E_CONCURRENT_DESTINATION]` while preserving detected concurrent state and cleaning temporary files. ### Security - unsafe binary, image, special-file, symlink, null-byte, and lossy UTF-8 rewrites are rejected before writing; - security reporting, sensitive-diagnostic handling, dependency review, and recovery responsibilities are documented. +- same-size and coarse-timestamp destination changes are no longer silently overwritten when detected before replacement; documentation explicitly records the residual check-to-rename race and makes no compare-and-swap guarantee. ## 0.1.0 diff --git a/README.md b/README.md index bc79d0a..466cfec 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,12 @@ Use the filesystem adapter, which detects and preserves newline style. Include a The adapter refuses directories, symbolic links, special files, images, null-byte/binary data, and invalid UTF-8 that would decode with replacement characters. Successful edits use a same-directory temporary file and atomic replacement, preserve UTF-8 BOMs and existing permission bits, and clean up temporary files after success or handled failure. Atomic replacement intentionally breaks the edited path out of a hard-link set; other hard links continue to reference the unchanged original inode. +### `[E_CONCURRENT_DESTINATION]` + +The destination changed after the filesystem adapter loaded it and before atomic replacement. The adapter detects changed bytes (using a SHA-256 digest, including same-size/coarse-timestamp changes), replacement identity/inode, deletion, and missing-to-created races. It preserves the concurrently changed destination, removes its temporary file, and returns this classified recovery error. Re-read, reassess the edit, and retry only with current anchors. + +This is best-effort optimistic detection, not compare-and-swap. A residual race remains between the final revalidation and `rename`, so a successful edit is not a guarantee that no concurrent writer intervened. + ## Development ```bash diff --git a/dist/src/filesystem-client.d.ts b/dist/src/filesystem-client.d.ts index ad98244..cbebdb1 100644 --- a/dist/src/filesystem-client.d.ts +++ b/dist/src/filesystem-client.d.ts @@ -1,6 +1,8 @@ import type { EditParams, PiClient, ReadParams } from './types.js'; export declare class FilesystemPiClient implements PiClient { + protected beforeDestinationRevalidation(_destinationPath: string): Promise; protected replaceTemporaryFile(temporaryPath: string, destinationPath: string): Promise; + private observeDestination; private atomicWrite; read({ path, offset, limit }: ReadParams): Promise; edit({ path, edits }: EditParams): Promise; diff --git a/dist/src/filesystem-client.js b/dist/src/filesystem-client.js index ca112c2..5fd886b 100644 --- a/dist/src/filesystem-client.js +++ b/dist/src/filesystem-client.js @@ -1,5 +1,5 @@ -import { chmod, mkdir, open, rename, rm, stat } from 'node:fs/promises'; -import { randomUUID } from 'node:crypto'; +import { chmod, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; import { basename, dirname, join } from 'node:path'; import { formatAnchors } from './anchors.js'; import { loadFileKindAndText } from './file-kind.js'; @@ -8,37 +8,149 @@ import { detectLineEnding, normalizeToLF, restoreLineEndings } from './text.js'; function splitLines(text) { return text.length === 0 ? [] : text.split(/\r?\n/); } +const CONCURRENT_DESTINATION_ERROR = 'E_CONCURRENT_DESTINATION'; +function digestBytes(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} +function sameIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino; +} +function sameObservation(left, right) { + if (left.state !== right.state) + return false; + if (left.state !== 'present' || right.state !== 'present') + return left.state === 'missing'; + return (sameIdentity(left, right) + && left.size === right.size + && left.digest === right.digest); +} +function concurrentDestinationError(path) { + return new Error(`[${CONCURRENT_DESTINATION_ERROR}] Refusing to replace ${path}: destination changed after it was loaded. Re-read and retry with current anchors.`); +} async function loadText(path) { + let before; try { - const loaded = await loadFileKindAndText(path); - switch (loaded.kind) { - case 'text': - if (loaded.hadUtf8DecodeErrors) { - throw new Error(`[E_DECODE_LOSS] Refusing to rewrite ${path}: invalid UTF-8 would be replaced.`); - } - return { text: loaded.text, mode: (await stat(path)).mode & 0o7777 }; - case 'directory': - throw new Error(`[E_UNSUPPORTED_FILE] Refusing to read directory: ${path}`); - case 'symlink': - throw new Error(`[E_UNSUPPORTED_FILE] Refusing to follow symbolic link: ${path}`); - case 'image': - throw new Error(`[E_BINARY_FILE] Refusing to read image (${loaded.mimeType}): ${path}`); - case 'binary': - throw new Error(`[E_BINARY_FILE] Refusing to read binary file (${loaded.description}): ${path}`); - } + before = await lstat(path, { bigint: true }); } catch (error) { if (error.code === 'ENOENT') { - return { text: '' }; + return { text: '', observation: { state: 'missing' } }; } throw error; } + let loaded; + try { + loaded = await loadFileKindAndText(path); + } + catch (error) { + if (error.code === 'ENOENT') + throw concurrentDestinationError(path); + throw error; + } + let after; + try { + after = await lstat(path, { bigint: true }); + } + catch (error) { + if (error.code === 'ENOENT') + throw concurrentDestinationError(path); + throw error; + } + if (!sameIdentity(before, after)) + throw concurrentDestinationError(path); + switch (loaded.kind) { + case 'text': { + if (loaded.hadUtf8DecodeErrors) { + throw new Error(`[E_DECODE_LOSS] Refusing to rewrite ${path}: invalid UTF-8 would be replaced.`); + } + let bytes; + let verified; + try { + bytes = await readFile(path); + verified = await lstat(path, { bigint: true }); + } + catch (error) { + if (error.code === 'ENOENT') + throw concurrentDestinationError(path); + throw error; + } + const decodedBytes = Buffer.from(loaded.text, 'utf8'); + if (!sameIdentity(after, verified) + || verified.size !== BigInt(bytes.length) + || !bytes.equals(decodedBytes)) { + throw concurrentDestinationError(path); + } + return { + text: loaded.text, + mode: Number(verified.mode & 4095n), + observation: { + state: 'present', + dev: verified.dev, + ino: verified.ino, + size: verified.size, + digest: digestBytes(bytes), + }, + }; + } + case 'directory': + throw new Error(`[E_UNSUPPORTED_FILE] Refusing to read directory: ${path}`); + case 'symlink': + throw new Error(`[E_UNSUPPORTED_FILE] Refusing to follow symbolic link: ${path}`); + case 'image': + throw new Error(`[E_BINARY_FILE] Refusing to read image (${loaded.mimeType}): ${path}`); + case 'binary': + throw new Error(`[E_BINARY_FILE] Refusing to read binary file (${loaded.description}): ${path}`); + } } export class FilesystemPiClient { + async beforeDestinationRevalidation(_destinationPath) { } async replaceTemporaryFile(temporaryPath, destinationPath) { await rename(temporaryPath, destinationPath); } - async atomicWrite(path, content, mode) { + async observeDestination(path) { + let pathBefore; + try { + pathBefore = await lstat(path, { bigint: true }); + } + catch (error) { + if (error.code === 'ENOENT') + return { state: 'missing' }; + throw error; + } + if (!pathBefore.isFile()) + return { state: 'unstable' }; + let handle; + try { + handle = await open(path, 'r'); + const openedBefore = await handle.stat({ bigint: true }); + const bytes = await handle.readFile(); + const openedAfter = await handle.stat({ bigint: true }); + const pathAfter = await lstat(path, { bigint: true }); + if (!pathAfter.isFile() + || !sameIdentity(pathBefore, openedBefore) + || !sameIdentity(openedBefore, openedAfter) + || !sameIdentity(openedAfter, pathAfter) + || openedAfter.size !== BigInt(bytes.length)) { + return { state: 'unstable' }; + } + return { + state: 'present', + dev: pathAfter.dev, + ino: pathAfter.ino, + size: pathAfter.size, + digest: digestBytes(bytes), + }; + } + catch (error) { + if (error.code === 'ENOENT') + return { state: 'missing' }; + throw error; + } + finally { + await handle?.close().catch(() => undefined); + } + } + async atomicWrite(path, content, mode, observation) { const parent = dirname(path); await mkdir(parent, { recursive: true }); const temporaryPath = join(parent, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`); @@ -51,6 +163,11 @@ export class FilesystemPiClient { handle = undefined; if (mode !== undefined) await chmod(temporaryPath, mode); + await this.beforeDestinationRevalidation(path); + const currentObservation = await this.observeDestination(path); + if (!sameObservation(observation, currentObservation)) + throw concurrentDestinationError(path); + // Best-effort only: the destination can still change after this check and before rename. await this.replaceTemporaryFile(temporaryPath, path); } finally { @@ -66,7 +183,17 @@ export class FilesystemPiClient { return formatAnchors(slice, offset); } async edit({ path, edits }) { - const { text: raw, mode } = await loadText(path); + let loaded; + try { + loaded = await loadText(path); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith(`[${CONCURRENT_DESTINATION_ERROR}]`)) + return message; + throw error; + } + const { text: raw, mode, observation } = loaded; const ending = detectLineEnding(raw); let normalized = normalizeToLF(raw); try { @@ -79,7 +206,15 @@ export class FilesystemPiClient { return message; throw error; } - await this.atomicWrite(path, restoreLineEndings(normalized, ending), mode); + try { + await this.atomicWrite(path, restoreLineEndings(normalized, ending), mode, observation); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith(`[${CONCURRENT_DESTINATION_ERROR}]`)) + return message; + throw error; + } return formatAnchors(splitLines(normalized)); } } diff --git a/dist/test/filesystem-client.test.js b/dist/test/filesystem-client.test.js index d7d6c43..ab103ee 100644 --- a/dist/test/filesystem-client.test.js +++ b/dist/test/filesystem-client.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { chmod, link, lstat, mkdtemp, readFile, readdir, stat, symlink, writeFile, } from 'node:fs/promises'; +import { chmod, link, lstat, mkdtemp, readFile, readdir, rename, rm, stat, symlink, utimes, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import { FilesystemPiClient, loadFileKindAndText } from '../src/index.js'; @@ -18,6 +18,22 @@ class FailingReplacementClient extends FilesystemPiClient { throw new Error('simulated replacement failure'); } } +class RevalidationRaceClient extends FilesystemPiClient { + race; + constructor(race) { + super(); + this.race = race; + } + async beforeDestinationRevalidation(destinationPath) { + await this.race(destinationPath); + } +} +function expectedConcurrentDestinationError(path) { + return `[E_CONCURRENT_DESTINATION] Refusing to replace ${path}: destination changed after it was loaded. Re-read and retry with current anchors.`; +} +async function assertNoTemporaryFiles(dir) { + assert.deepEqual((await readdir(dir)).filter((entry) => entry.includes('.tmp')), []); +} test('classifies empty and ordinary UTF-8 text', async () => { const { path } = await fixture(); await writeFile(path, ''); @@ -88,6 +104,71 @@ test('successful replacement leaves no temporary file', async () => { await client.edit({ path, edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }] }); assert.deepEqual((await readdir(dir)).filter((entry) => entry.includes('.tmp')), []); }); +test('detects a same-size content change even when timestamps are restored', async () => { + const { dir, path } = await fixture(); + await writeFile(path, 'one\ntwo'); + const preview = await new FilesystemPiClient().read({ path }); + const before = await stat(path); + const concurrentContent = 'red\nblu'; + assert.equal(Buffer.byteLength(concurrentContent), Buffer.byteLength('one\ntwo')); + const client = new RevalidationRaceClient(async (destinationPath) => { + await writeFile(destinationPath, concurrentContent); + await utimes(destinationPath, before.atime, before.mtime); + }); + const result = await client.edit({ + path, + edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }], + }); + assert.equal(result, expectedConcurrentDestinationError(path)); + assert.equal(await readFile(path, 'utf8'), concurrentContent); + await assertNoTemporaryFiles(dir); +}); +test('detects a same-content destination replacement by inode', async () => { + const { dir, path } = await fixture(); + const replacementPath = join(dir, 'replacement.txt'); + const originalContent = 'one\ntwo'; + await writeFile(path, originalContent); + const before = await lstat(path); + const preview = await new FilesystemPiClient().read({ path }); + const client = new RevalidationRaceClient(async (destinationPath) => { + await writeFile(replacementPath, originalContent); + await rename(replacementPath, destinationPath); + }); + const result = await client.edit({ + path, + edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }], + }); + assert.equal(result, expectedConcurrentDestinationError(path)); + assert.equal(await readFile(path, 'utf8'), originalContent); + assert.notEqual((await lstat(path)).ino, before.ino); + await assertNoTemporaryFiles(dir); +}); +test('detects destination deletion and does not recreate it', async () => { + const { dir, path } = await fixture(); + await writeFile(path, 'one\ntwo'); + const preview = await new FilesystemPiClient().read({ path }); + const client = new RevalidationRaceClient(async (destinationPath) => { + await rm(destinationPath); + }); + const result = await client.edit({ + path, + edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }], + }); + assert.equal(result, expectedConcurrentDestinationError(path)); + await assert.rejects(() => readFile(path), { code: 'ENOENT' }); + await assertNoTemporaryFiles(dir); +}); +test('detects a missing destination created before replacement', async () => { + const { dir, path } = await fixture('nested/new.txt'); + const concurrentContent = 'created by another writer'; + const client = new RevalidationRaceClient(async (destinationPath) => { + await writeFile(destinationPath, concurrentContent); + }); + const result = await client.edit({ path, edits: [{ op: 'prepend', lines: ['ours'] }] }); + assert.equal(result, expectedConcurrentDestinationError(path)); + assert.equal(await readFile(path, 'utf8'), concurrentContent); + await assertNoTemporaryFiles(join(dir, 'nested')); +}); test('filesystem client supports every edit operation and returns classified failures', async () => { const client = new FilesystemPiClient(); const { path } = await fixture(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 44ffd3a..ce344ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,7 +18,8 @@ 3. The core parses references and verifies current line hashes/text hints. 4. Operations are validated for overlap, uniqueness, and invalid rendered prefixes. 5. The engine produces new content or throws a classified actionable error. -6. The filesystem adapter classifies the path before decoding, preserves newline/BOM/mode behavior, and atomically replaces the selected directory entry. +6. The filesystem adapter classifies the path before decoding, captures destination existence, file identity, byte length, and a SHA-256 digest, and preserves newline/BOM/mode behavior. +7. Immediately before atomic replacement, it re-observes the destination and aborts with `[E_CONCURRENT_DESTINATION]` if existence, identity, length, or digest differs. ## Invariants @@ -30,3 +31,5 @@ - Symbolic links are rejected rather than followed. Atomic replacement of a hard-linked path changes only that path; sibling links retain the prior inode and content. - Binary, image, special-file, null-byte, and decode-loss inputs are rejected before any write. - Temporary files are created beside the destination so replacement stays on the same filesystem, and are removed after success or handled failure. +- Revalidation uses byte-digest evidence rather than size or timestamps alone, so same-size changes and changes hidden by coarse timestamp resolution are detected. +- The concurrency guard is optimistic and best-effort, not compare-and-swap: a destination can still change in the residual interval after revalidation and before `rename`. The library makes no false CAS guarantee. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 1712c64..3ff54a8 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -4,7 +4,7 @@ Authorize the caller-selected path, keep a recoverable copy when content is important, read immediately before editing, and use only anchors from that read. Do not place secrets or private file content in bug reports. Use synthetic fixtures when reproducing failures. -The filesystem adapter performs same-directory temporary-file replacement. A handled failure removes its temporary file; the original path remains unchanged when replacement fails. Symbolic links are rejected. Editing one name in a hard-link set replaces only that directory entry, so sibling hard links retain the old inode and content. +The filesystem adapter performs same-directory temporary-file replacement. It records destination existence, file identity, byte length, and a SHA-256 byte digest when loading, then revalidates immediately before replacement. A detected content change, destination replacement, deletion, or missing-to-created race preserves the concurrent state and removes the adapter's temporary file. Symbolic links are rejected. Editing one name in a hard-link set replaces only that directory entry, so sibling hard links retain the old inode and content. ## Classified errors @@ -22,6 +22,7 @@ The filesystem adapter performs same-directory temporary-file replacement. A han | `[E_BINARY_FILE]` | The classifier detected an image, binary MIME type, or null bytes. | Do not edit with this library. Select a format-aware binary tool and preserve the original. | | `[E_DECODE_LOSS]` | UTF-8 decoding would replace invalid bytes. | Do not rewrite. Determine the real encoding and use an encoding-aware conversion with an explicit backup. | | `[E_UNSUPPORTED_FILE]` | The path is a directory, symbolic link, or unsupported special file. | Resolve and authorize a regular-file path explicitly; do not weaken the classifier or follow the link implicitly. | +| `[E_CONCURRENT_DESTINATION]` | The destination's existence, identity, length, or byte digest changed after it was loaded and before replacement. | Preserve the concurrent destination, re-read it, reassess intent, and retry only with current anchors. | `Operation aborted` means the supplied abort signal was already cancelled; leave content unchanged, determine whether the caller still wants the operation, then re-read before retrying. @@ -29,6 +30,8 @@ The filesystem adapter performs same-directory temporary-file replacement. A han Node filesystem errors such as `EACCES`, `EPERM`, `EROFS`, `ENOSPC`, `EMFILE`, and unexpected `ENOENT` are propagated. Do not elevate privileges automatically. Confirm directory authorization and available space, preserve the original, inspect the same directory for a `.filename...tmp` residue after an unhandled process termination, and remove a residue only after confirming no live process owns it. A missing path is treated as empty for an intentional prepend/append creation; callers must distinguish intentional creation from a misspelled path. +The optimistic guard deliberately compares a byte digest rather than trusting size and timestamps, so same-size edits and coarse-time metadata collisions are detected. It is still a best-effort check, not an atomic compare-and-swap: another writer can change the destination after revalidation and before `rename`. Callers must not treat a successful edit as proof that no writer raced in that residual interval. + ## Escalation data A safe report contains: diff --git a/src/filesystem-client.ts b/src/filesystem-client.ts index 875d770..46eccf7 100644 --- a/src/filesystem-client.ts +++ b/src/filesystem-client.ts @@ -1,5 +1,5 @@ -import { chmod, mkdir, open, rename, rm, stat } from 'node:fs/promises'; -import { randomUUID } from 'node:crypto'; +import { chmod, lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; import { basename, dirname, join } from 'node:path'; import { formatAnchors } from './anchors.js'; import { loadFileKindAndText } from './file-kind.js'; @@ -11,41 +11,174 @@ function splitLines(text: string): string[] { return text.length === 0 ? [] : text.split(/\r?\n/); } -type LoadedText = { text: string; mode?: number }; +type DestinationObservation = + | { state: 'missing' } + | { state: 'present'; dev: bigint; ino: bigint; size: bigint; digest: string } + | { state: 'unstable' }; + +type LoadedText = { text: string; mode?: number; observation: DestinationObservation }; + +const CONCURRENT_DESTINATION_ERROR = 'E_CONCURRENT_DESTINATION'; + +function digestBytes(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function sameIdentity( + left: { dev: bigint; ino: bigint }, + right: { dev: bigint; ino: bigint }, +): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function sameObservation(left: DestinationObservation, right: DestinationObservation): boolean { + if (left.state !== right.state) return false; + if (left.state !== 'present' || right.state !== 'present') return left.state === 'missing'; + return ( + sameIdentity(left, right) + && left.size === right.size + && left.digest === right.digest + ); +} + +function concurrentDestinationError(path: string): Error { + return new Error( + `[${CONCURRENT_DESTINATION_ERROR}] Refusing to replace ${path}: destination changed after it was loaded. Re-read and retry with current anchors.`, + ); +} async function loadText(path: string): Promise { + let before: Awaited>; try { - const loaded = await loadFileKindAndText(path); - switch (loaded.kind) { - case 'text': - if (loaded.hadUtf8DecodeErrors) { - throw new Error(`[E_DECODE_LOSS] Refusing to rewrite ${path}: invalid UTF-8 would be replaced.`); - } - return { text: loaded.text, mode: (await stat(path)).mode & 0o7777 }; - case 'directory': - throw new Error(`[E_UNSUPPORTED_FILE] Refusing to read directory: ${path}`); - case 'symlink': - throw new Error(`[E_UNSUPPORTED_FILE] Refusing to follow symbolic link: ${path}`); - case 'image': - throw new Error(`[E_BINARY_FILE] Refusing to read image (${loaded.mimeType}): ${path}`); - case 'binary': - throw new Error(`[E_BINARY_FILE] Refusing to read binary file (${loaded.description}): ${path}`); - } + before = await lstat(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { text: '' }; + return { text: '', observation: { state: 'missing' } }; } throw error; } + + let loaded: Awaited>; + try { + loaded = await loadFileKindAndText(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw concurrentDestinationError(path); + throw error; + } + + let after: Awaited>; + try { + after = await lstat(path, { bigint: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw concurrentDestinationError(path); + throw error; + } + + if (!sameIdentity(before, after)) throw concurrentDestinationError(path); + + switch (loaded.kind) { + case 'text': { + if (loaded.hadUtf8DecodeErrors) { + throw new Error(`[E_DECODE_LOSS] Refusing to rewrite ${path}: invalid UTF-8 would be replaced.`); + } + let bytes: Buffer; + let verified: Awaited>; + try { + bytes = await readFile(path); + verified = await lstat(path, { bigint: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw concurrentDestinationError(path); + throw error; + } + const decodedBytes = Buffer.from(loaded.text, 'utf8'); + if ( + !sameIdentity(after, verified) + || verified.size !== BigInt(bytes.length) + || !bytes.equals(decodedBytes) + ) { + throw concurrentDestinationError(path); + } + return { + text: loaded.text, + mode: Number(verified.mode & 0o7777n), + observation: { + state: 'present', + dev: verified.dev, + ino: verified.ino, + size: verified.size, + digest: digestBytes(bytes), + }, + }; + } + case 'directory': + throw new Error(`[E_UNSUPPORTED_FILE] Refusing to read directory: ${path}`); + case 'symlink': + throw new Error(`[E_UNSUPPORTED_FILE] Refusing to follow symbolic link: ${path}`); + case 'image': + throw new Error(`[E_BINARY_FILE] Refusing to read image (${loaded.mimeType}): ${path}`); + case 'binary': + throw new Error(`[E_BINARY_FILE] Refusing to read binary file (${loaded.description}): ${path}`); + } } export class FilesystemPiClient implements PiClient { + protected async beforeDestinationRevalidation(_destinationPath: string): Promise {} + protected async replaceTemporaryFile(temporaryPath: string, destinationPath: string): Promise { await rename(temporaryPath, destinationPath); } - private async atomicWrite(path: string, content: string, mode?: number): Promise { + private async observeDestination(path: string): Promise { + let pathBefore: Awaited>; + try { + pathBefore = await lstat(path, { bigint: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { state: 'missing' }; + throw error; + } + + if (!pathBefore.isFile()) return { state: 'unstable' }; + + let handle: Awaited> | undefined; + try { + handle = await open(path, 'r'); + const openedBefore = await handle.stat({ bigint: true }); + const bytes = await handle.readFile(); + const openedAfter = await handle.stat({ bigint: true }); + const pathAfter = await lstat(path, { bigint: true }); + + if ( + !pathAfter.isFile() + || !sameIdentity(pathBefore, openedBefore) + || !sameIdentity(openedBefore, openedAfter) + || !sameIdentity(openedAfter, pathAfter) + || openedAfter.size !== BigInt(bytes.length) + ) { + return { state: 'unstable' }; + } + + return { + state: 'present', + dev: pathAfter.dev, + ino: pathAfter.ino, + size: pathAfter.size, + digest: digestBytes(bytes), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { state: 'missing' }; + throw error; + } finally { + await handle?.close().catch(() => undefined); + } + } + + private async atomicWrite( + path: string, + content: string, + mode: number | undefined, + observation: DestinationObservation, + ): Promise { const parent = dirname(path); await mkdir(parent, { recursive: true }); const temporaryPath = join(parent, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`); @@ -58,6 +191,10 @@ export class FilesystemPiClient implements PiClient { await handle.close(); handle = undefined; if (mode !== undefined) await chmod(temporaryPath, mode); + await this.beforeDestinationRevalidation(path); + const currentObservation = await this.observeDestination(path); + if (!sameObservation(observation, currentObservation)) throw concurrentDestinationError(path); + // Best-effort only: the destination can still change after this check and before rename. await this.replaceTemporaryFile(temporaryPath, path); } finally { await handle?.close().catch(() => undefined); @@ -74,7 +211,15 @@ export class FilesystemPiClient implements PiClient { } async edit({ path, edits }: EditParams): Promise { - const { text: raw, mode } = await loadText(path); + let loaded: LoadedText; + try { + loaded = await loadText(path); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith(`[${CONCURRENT_DESTINATION_ERROR}]`)) return message; + throw error; + } + const { text: raw, mode, observation } = loaded; const ending = detectLineEnding(raw); let normalized = normalizeToLF(raw); @@ -90,7 +235,13 @@ export class FilesystemPiClient implements PiClient { throw error; } - await this.atomicWrite(path, restoreLineEndings(normalized, ending), mode); + try { + await this.atomicWrite(path, restoreLineEndings(normalized, ending), mode, observation); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith(`[${CONCURRENT_DESTINATION_ERROR}]`)) return message; + throw error; + } return formatAnchors(splitLines(normalized)); } } diff --git a/test/filesystem-client.test.ts b/test/filesystem-client.test.ts index 55038ac..0229d47 100644 --- a/test/filesystem-client.test.ts +++ b/test/filesystem-client.test.ts @@ -7,8 +7,11 @@ import { mkdtemp, readFile, readdir, + rename, + rm, stat, symlink, + utimes, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -32,6 +35,24 @@ class FailingReplacementClient extends FilesystemPiClient { } } +class RevalidationRaceClient extends FilesystemPiClient { + constructor(private readonly race: (destinationPath: string) => Promise) { + super(); + } + + protected override async beforeDestinationRevalidation(destinationPath: string): Promise { + await this.race(destinationPath); + } +} + +function expectedConcurrentDestinationError(path: string): string { + return `[E_CONCURRENT_DESTINATION] Refusing to replace ${path}: destination changed after it was loaded. Re-read and retry with current anchors.`; +} + +async function assertNoTemporaryFiles(dir: string): Promise { + assert.deepEqual((await readdir(dir)).filter((entry) => entry.includes('.tmp')), []); +} + test('classifies empty and ordinary UTF-8 text', async () => { const { path } = await fixture(); await writeFile(path, ''); @@ -121,6 +142,83 @@ test('successful replacement leaves no temporary file', async () => { assert.deepEqual((await readdir(dir)).filter((entry) => entry.includes('.tmp')), []); }); +test('detects a same-size content change even when timestamps are restored', async () => { + const { dir, path } = await fixture(); + await writeFile(path, 'one\ntwo'); + const preview = await new FilesystemPiClient().read({ path }); + const before = await stat(path); + const concurrentContent = 'red\nblu'; + assert.equal(Buffer.byteLength(concurrentContent), Buffer.byteLength('one\ntwo')); + + const client = new RevalidationRaceClient(async (destinationPath) => { + await writeFile(destinationPath, concurrentContent); + await utimes(destinationPath, before.atime, before.mtime); + }); + const result = await client.edit({ + path, + edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }], + }); + + assert.equal(result, expectedConcurrentDestinationError(path)); + assert.equal(await readFile(path, 'utf8'), concurrentContent); + await assertNoTemporaryFiles(dir); +}); + +test('detects a same-content destination replacement by inode', async () => { + const { dir, path } = await fixture(); + const replacementPath = join(dir, 'replacement.txt'); + const originalContent = 'one\ntwo'; + await writeFile(path, originalContent); + const before = await lstat(path); + const preview = await new FilesystemPiClient().read({ path }); + + const client = new RevalidationRaceClient(async (destinationPath) => { + await writeFile(replacementPath, originalContent); + await rename(replacementPath, destinationPath); + }); + const result = await client.edit({ + path, + edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }], + }); + + assert.equal(result, expectedConcurrentDestinationError(path)); + assert.equal(await readFile(path, 'utf8'), originalContent); + assert.notEqual((await lstat(path)).ino, before.ino); + await assertNoTemporaryFiles(dir); +}); + +test('detects destination deletion and does not recreate it', async () => { + const { dir, path } = await fixture(); + await writeFile(path, 'one\ntwo'); + const preview = await new FilesystemPiClient().read({ path }); + const client = new RevalidationRaceClient(async (destinationPath) => { + await rm(destinationPath); + }); + + const result = await client.edit({ + path, + edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }], + }); + + assert.equal(result, expectedConcurrentDestinationError(path)); + await assert.rejects(() => readFile(path), { code: 'ENOENT' }); + await assertNoTemporaryFiles(dir); +}); + +test('detects a missing destination created before replacement', async () => { + const { dir, path } = await fixture('nested/new.txt'); + const concurrentContent = 'created by another writer'; + const client = new RevalidationRaceClient(async (destinationPath) => { + await writeFile(destinationPath, concurrentContent); + }); + + const result = await client.edit({ path, edits: [{ op: 'prepend', lines: ['ours'] }] }); + + assert.equal(result, expectedConcurrentDestinationError(path)); + assert.equal(await readFile(path, 'utf8'), concurrentContent); + await assertNoTemporaryFiles(join(dir, 'nested')); +}); + test('filesystem client supports every edit operation and returns classified failures', async () => { const client = new FilesystemPiClient(); const { path } = await fixture(); From 3d40e5abd7256a9102958d0dcfaa512ea92bc992 Mon Sep 17 00:00:00 2001 From: cervantesh <11169707+cervantesh@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:20:47 -0400 Subject: [PATCH 2/2] fix: detect concurrent destination mode changes --- CHANGELOG.md | 6 +++--- README.md | 2 +- dist/src/filesystem-client.js | 12 +++++++++++- dist/test/filesystem-client.test.js | 22 ++++++++++++++++++++++ docs/ARCHITECTURE.md | 6 +++--- docs/OPERATIONS.md | 6 +++--- src/filesystem-client.ts | 15 +++++++++++++-- test/filesystem-client.test.ts | 26 ++++++++++++++++++++++++++ 8 files changed, 82 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 755241b..1177383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,19 +10,19 @@ - Linux, Windows, macOS, Node 22, and Node 24 CI coverage; - package provenance/license verification and immutable GitHub release artifact creation; - weekly Dependabot and CodeQL scanning workflows. -- deterministic filesystem race fixtures for changed content, replacement identity, deletion, and missing-to-created destinations. +- deterministic filesystem race fixtures for changed content, permission mode, replacement identity, deletion, and missing-to-created destinations. ### Changed - filesystem edits now classify paths before decoding and use same-directory atomic replacement with mode, BOM, newline, and cleanup guarantees; - package contents exclude compiled tests and include complete repository provenance metadata. -- filesystem atomic replacement now performs best-effort optimistic destination revalidation with identity and SHA-256 byte-digest evidence, returning `[E_CONCURRENT_DESTINATION]` while preserving detected concurrent state and cleaning temporary files. +- filesystem atomic replacement now performs best-effort optimistic destination revalidation with identity, permission-mode, and SHA-256 byte-digest evidence, returning `[E_CONCURRENT_DESTINATION]` while preserving detected concurrent state and cleaning temporary files. ### Security - unsafe binary, image, special-file, symlink, null-byte, and lossy UTF-8 rewrites are rejected before writing; - security reporting, sensitive-diagnostic handling, dependency review, and recovery responsibilities are documented. -- same-size and coarse-timestamp destination changes are no longer silently overwritten when detected before replacement; documentation explicitly records the residual check-to-rename race and makes no compare-and-swap guarantee. +- same-size, coarse-timestamp, and permission-only destination changes are no longer silently overwritten when detected before replacement; documentation explicitly records the residual check-to-rename race and makes no compare-and-swap guarantee. ## 0.1.0 diff --git a/README.md b/README.md index 466cfec..8bac6fb 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ The adapter refuses directories, symbolic links, special files, images, null-byt ### `[E_CONCURRENT_DESTINATION]` -The destination changed after the filesystem adapter loaded it and before atomic replacement. The adapter detects changed bytes (using a SHA-256 digest, including same-size/coarse-timestamp changes), replacement identity/inode, deletion, and missing-to-created races. It preserves the concurrently changed destination, removes its temporary file, and returns this classified recovery error. Re-read, reassess the edit, and retry only with current anchors. +The destination changed after the filesystem adapter loaded it and before atomic replacement. The adapter detects changed bytes (using a SHA-256 digest, including same-size/coarse-timestamp changes), permission-mode changes, replacement identity/inode, deletion, and missing-to-created races. It preserves the concurrently changed destination, including its current permission mode, removes its temporary file, and returns this classified recovery error. Re-read, reassess the edit, and retry only with current anchors. This is best-effort optimistic detection, not compare-and-swap. A residual race remains between the final revalidation and `rename`, so a successful edit is not a guarantee that no concurrent writer intervened. diff --git a/dist/src/filesystem-client.js b/dist/src/filesystem-client.js index 5fd886b..a8bc1d0 100644 --- a/dist/src/filesystem-client.js +++ b/dist/src/filesystem-client.js @@ -15,6 +15,9 @@ function digestBytes(bytes) { function sameIdentity(left, right) { return left.dev === right.dev && left.ino === right.ino; } +function permissionMode(stats) { + return Number(stats.mode & 4095n); +} function sameObservation(left, right) { if (left.state !== right.state) return false; @@ -22,6 +25,7 @@ function sameObservation(left, right) { return left.state === 'missing'; return (sameIdentity(left, right) && left.size === right.size + && left.mode === right.mode && left.digest === right.digest); } function concurrentDestinationError(path) { @@ -80,14 +84,16 @@ async function loadText(path) { || !bytes.equals(decodedBytes)) { throw concurrentDestinationError(path); } + const mode = permissionMode(verified); return { text: loaded.text, - mode: Number(verified.mode & 4095n), + mode, observation: { state: 'present', dev: verified.dev, ino: verified.ino, size: verified.size, + mode, digest: digestBytes(bytes), }, }; @@ -130,6 +136,9 @@ export class FilesystemPiClient { || !sameIdentity(pathBefore, openedBefore) || !sameIdentity(openedBefore, openedAfter) || !sameIdentity(openedAfter, pathAfter) + || permissionMode(pathBefore) !== permissionMode(openedBefore) + || permissionMode(openedBefore) !== permissionMode(openedAfter) + || permissionMode(openedAfter) !== permissionMode(pathAfter) || openedAfter.size !== BigInt(bytes.length)) { return { state: 'unstable' }; } @@ -138,6 +147,7 @@ export class FilesystemPiClient { dev: pathAfter.dev, ino: pathAfter.ino, size: pathAfter.size, + mode: permissionMode(pathAfter), digest: digestBytes(bytes), }; } diff --git a/dist/test/filesystem-client.test.js b/dist/test/filesystem-client.test.js index ab103ee..fa4aa5f 100644 --- a/dist/test/filesystem-client.test.js +++ b/dist/test/filesystem-client.test.js @@ -123,6 +123,28 @@ test('detects a same-size content change even when timestamps are restored', asy assert.equal(await readFile(path, 'utf8'), concurrentContent); await assertNoTemporaryFiles(dir); }); +test('detects a permission-only destination change without restoring the stale mode', async (t) => { + if (process.platform === 'win32') { + t.skip('permission-bit race assertion unavailable on Windows'); + return; + } + const { dir, path } = await fixture(); + const originalContent = 'one\ntwo'; + await writeFile(path, originalContent); + await chmod(path, 0o644); + const preview = await new FilesystemPiClient().read({ path }); + const client = new RevalidationRaceClient(async (destinationPath) => { + await chmod(destinationPath, 0o600); + }); + const result = await client.edit({ + path, + edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }], + }); + assert.equal(result, expectedConcurrentDestinationError(path)); + assert.equal(await readFile(path, 'utf8'), originalContent); + assert.equal((await stat(path)).mode & 0o777, 0o600); + await assertNoTemporaryFiles(dir); +}); test('detects a same-content destination replacement by inode', async () => { const { dir, path } = await fixture(); const replacementPath = join(dir, 'replacement.txt'); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ce344ad..4f1508a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,8 +18,8 @@ 3. The core parses references and verifies current line hashes/text hints. 4. Operations are validated for overlap, uniqueness, and invalid rendered prefixes. 5. The engine produces new content or throws a classified actionable error. -6. The filesystem adapter classifies the path before decoding, captures destination existence, file identity, byte length, and a SHA-256 digest, and preserves newline/BOM/mode behavior. -7. Immediately before atomic replacement, it re-observes the destination and aborts with `[E_CONCURRENT_DESTINATION]` if existence, identity, length, or digest differs. +6. The filesystem adapter classifies the path before decoding, captures destination existence, file identity, byte length, permission mode, and a SHA-256 digest, and preserves newline/BOM/mode behavior. +7. Immediately before atomic replacement, it re-observes the destination and aborts with `[E_CONCURRENT_DESTINATION]` if existence, identity, length, permission mode, or digest differs. ## Invariants @@ -31,5 +31,5 @@ - Symbolic links are rejected rather than followed. Atomic replacement of a hard-linked path changes only that path; sibling links retain the prior inode and content. - Binary, image, special-file, null-byte, and decode-loss inputs are rejected before any write. - Temporary files are created beside the destination so replacement stays on the same filesystem, and are removed after success or handled failure. -- Revalidation uses byte-digest evidence rather than size or timestamps alone, so same-size changes and changes hidden by coarse timestamp resolution are detected. +- Revalidation uses permission-mode and byte-digest evidence rather than size or timestamps alone, so permission-only changes, same-size content changes, and changes hidden by coarse timestamp resolution are detected. - The concurrency guard is optimistic and best-effort, not compare-and-swap: a destination can still change in the residual interval after revalidation and before `rename`. The library makes no false CAS guarantee. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 3ff54a8..2267227 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -4,7 +4,7 @@ Authorize the caller-selected path, keep a recoverable copy when content is important, read immediately before editing, and use only anchors from that read. Do not place secrets or private file content in bug reports. Use synthetic fixtures when reproducing failures. -The filesystem adapter performs same-directory temporary-file replacement. It records destination existence, file identity, byte length, and a SHA-256 byte digest when loading, then revalidates immediately before replacement. A detected content change, destination replacement, deletion, or missing-to-created race preserves the concurrent state and removes the adapter's temporary file. Symbolic links are rejected. Editing one name in a hard-link set replaces only that directory entry, so sibling hard links retain the old inode and content. +The filesystem adapter performs same-directory temporary-file replacement. It records destination existence, file identity, byte length, permission mode, and a SHA-256 byte digest when loading, then revalidates immediately before replacement. A detected content change, permission-mode change, destination replacement, deletion, or missing-to-created race preserves the concurrent state and removes the adapter's temporary file. Symbolic links are rejected. Editing one name in a hard-link set replaces only that directory entry, so sibling hard links retain the old inode and content. ## Classified errors @@ -22,7 +22,7 @@ The filesystem adapter performs same-directory temporary-file replacement. It re | `[E_BINARY_FILE]` | The classifier detected an image, binary MIME type, or null bytes. | Do not edit with this library. Select a format-aware binary tool and preserve the original. | | `[E_DECODE_LOSS]` | UTF-8 decoding would replace invalid bytes. | Do not rewrite. Determine the real encoding and use an encoding-aware conversion with an explicit backup. | | `[E_UNSUPPORTED_FILE]` | The path is a directory, symbolic link, or unsupported special file. | Resolve and authorize a regular-file path explicitly; do not weaken the classifier or follow the link implicitly. | -| `[E_CONCURRENT_DESTINATION]` | The destination's existence, identity, length, or byte digest changed after it was loaded and before replacement. | Preserve the concurrent destination, re-read it, reassess intent, and retry only with current anchors. | +| `[E_CONCURRENT_DESTINATION]` | The destination's existence, identity, length, permission mode, or byte digest changed after it was loaded and before replacement. | Preserve the concurrent destination, re-read it, reassess intent, and retry only with current anchors. | `Operation aborted` means the supplied abort signal was already cancelled; leave content unchanged, determine whether the caller still wants the operation, then re-read before retrying. @@ -30,7 +30,7 @@ The filesystem adapter performs same-directory temporary-file replacement. It re Node filesystem errors such as `EACCES`, `EPERM`, `EROFS`, `ENOSPC`, `EMFILE`, and unexpected `ENOENT` are propagated. Do not elevate privileges automatically. Confirm directory authorization and available space, preserve the original, inspect the same directory for a `.filename...tmp` residue after an unhandled process termination, and remove a residue only after confirming no live process owns it. A missing path is treated as empty for an intentional prepend/append creation; callers must distinguish intentional creation from a misspelled path. -The optimistic guard deliberately compares a byte digest rather than trusting size and timestamps, so same-size edits and coarse-time metadata collisions are detected. It is still a best-effort check, not an atomic compare-and-swap: another writer can change the destination after revalidation and before `rename`. Callers must not treat a successful edit as proof that no writer raced in that residual interval. +The optimistic guard deliberately compares permission mode and a byte digest rather than trusting size and timestamps, so permission-only changes, same-size edits, and coarse-time metadata collisions are detected. It is still a best-effort check, not an atomic compare-and-swap: another writer can change the destination after revalidation and before `rename`. Callers must not treat a successful edit as proof that no writer raced in that residual interval. ## Escalation data diff --git a/src/filesystem-client.ts b/src/filesystem-client.ts index 46eccf7..fb0e411 100644 --- a/src/filesystem-client.ts +++ b/src/filesystem-client.ts @@ -13,7 +13,7 @@ function splitLines(text: string): string[] { type DestinationObservation = | { state: 'missing' } - | { state: 'present'; dev: bigint; ino: bigint; size: bigint; digest: string } + | { state: 'present'; dev: bigint; ino: bigint; size: bigint; mode: number; digest: string } | { state: 'unstable' }; type LoadedText = { text: string; mode?: number; observation: DestinationObservation }; @@ -31,12 +31,17 @@ function sameIdentity( return left.dev === right.dev && left.ino === right.ino; } +function permissionMode(stats: { mode: bigint }): number { + return Number(stats.mode & 0o7777n); +} + function sameObservation(left: DestinationObservation, right: DestinationObservation): boolean { if (left.state !== right.state) return false; if (left.state !== 'present' || right.state !== 'present') return left.state === 'missing'; return ( sameIdentity(left, right) && left.size === right.size + && left.mode === right.mode && left.digest === right.digest ); } @@ -98,14 +103,16 @@ async function loadText(path: string): Promise { ) { throw concurrentDestinationError(path); } + const mode = permissionMode(verified); return { text: loaded.text, - mode: Number(verified.mode & 0o7777n), + mode, observation: { state: 'present', dev: verified.dev, ino: verified.ino, size: verified.size, + mode, digest: digestBytes(bytes), }, }; @@ -153,6 +160,9 @@ export class FilesystemPiClient implements PiClient { || !sameIdentity(pathBefore, openedBefore) || !sameIdentity(openedBefore, openedAfter) || !sameIdentity(openedAfter, pathAfter) + || permissionMode(pathBefore) !== permissionMode(openedBefore) + || permissionMode(openedBefore) !== permissionMode(openedAfter) + || permissionMode(openedAfter) !== permissionMode(pathAfter) || openedAfter.size !== BigInt(bytes.length) ) { return { state: 'unstable' }; @@ -163,6 +173,7 @@ export class FilesystemPiClient implements PiClient { dev: pathAfter.dev, ino: pathAfter.ino, size: pathAfter.size, + mode: permissionMode(pathAfter), digest: digestBytes(bytes), }; } catch (error) { diff --git a/test/filesystem-client.test.ts b/test/filesystem-client.test.ts index 0229d47..1378ad9 100644 --- a/test/filesystem-client.test.ts +++ b/test/filesystem-client.test.ts @@ -164,6 +164,32 @@ test('detects a same-size content change even when timestamps are restored', asy await assertNoTemporaryFiles(dir); }); +test('detects a permission-only destination change without restoring the stale mode', async (t) => { + if (process.platform === 'win32') { + t.skip('permission-bit race assertion unavailable on Windows'); + return; + } + + const { dir, path } = await fixture(); + const originalContent = 'one\ntwo'; + await writeFile(path, originalContent); + await chmod(path, 0o644); + const preview = await new FilesystemPiClient().read({ path }); + + const client = new RevalidationRaceClient(async (destinationPath) => { + await chmod(destinationPath, 0o600); + }); + const result = await client.edit({ + path, + edits: [{ op: 'replace', pos: secondAnchor(preview), lines: ['patched'] }], + }); + + assert.equal(result, expectedConcurrentDestinationError(path)); + assert.equal(await readFile(path, 'utf8'), originalContent); + assert.equal((await stat(path)).mode & 0o777, 0o600); + await assertNoTemporaryFiles(dir); +}); + test('detects a same-content destination replacement by inode', async () => { const { dir, path } = await fixture(); const replacementPath = join(dir, 'replacement.txt');