Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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, 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, 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

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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), 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.

## Development

```bash
Expand Down
2 changes: 2 additions & 0 deletions dist/src/filesystem-client.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { EditParams, PiClient, ReadParams } from './types.js';
export declare class FilesystemPiClient implements PiClient {
protected beforeDestinationRevalidation(_destinationPath: string): Promise<void>;
protected replaceTemporaryFile(temporaryPath: string, destinationPath: string): Promise<void>;
private observeDestination;
private atomicWrite;
read({ path, offset, limit }: ReadParams): Promise<string>;
edit({ path, edits }: EditParams): Promise<string>;
Expand Down
189 changes: 167 additions & 22 deletions dist/src/filesystem-client.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,37 +8,159 @@ 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 permissionMode(stats) {
return Number(stats.mode & 4095n);
}
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.mode === right.mode
&& 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);
}
const mode = permissionMode(verified);
return {
text: loaded.text,
mode,
observation: {
state: 'present',
dev: verified.dev,
ino: verified.ino,
size: verified.size,
mode,
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)
|| permissionMode(pathBefore) !== permissionMode(openedBefore)
|| permissionMode(openedBefore) !== permissionMode(openedAfter)
|| permissionMode(openedAfter) !== permissionMode(pathAfter)
|| openedAfter.size !== BigInt(bytes.length)) {
return { state: 'unstable' };
}
return {
state: 'present',
dev: pathAfter.dev,
ino: pathAfter.ino,
size: pathAfter.size,
mode: permissionMode(pathAfter),
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`);
Expand All @@ -51,6 +173,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 {
Expand All @@ -66,7 +193,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 {
Expand All @@ -79,7 +216,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));
}
}
Loading
Loading