Skip to content
Closed
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
16 changes: 14 additions & 2 deletions packages/safe-bash/src/commands/truncate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FsError, getCommandArguments, writeBytes, type CommandContext, type CommandDefinition, type FileReadHandle, type FileResizeHandle, type FileStat } from "../contracts/index.js";
import { FsError, getCommandArguments, writeBytes, type CommandContext, type CommandDefinition, type FileReadHandle, type FileResizeHandle, type FileResizeOperation, type FileStat } from "../contracts/index.js";
import { createOutputOperation } from "../contracts/output.js";
import { shellValueByteLength } from "../contracts/value.js";
import { yieldTurn } from "../contracts/yield.js";
Expand Down Expand Up @@ -324,7 +324,7 @@ export function truncateCommand(options: MetadataCommandsOptions = {}): CommandD
const configured = metadataSettings(options);
const argumentLimit = Math.min(65536, configured.limits.maxArgumentBytes);
const outputMaximum = Math.min(bufferLimit, configured.limits.maxOutputBytes);
return { name: "truncate", filesystemRequirements: [{ id: "resize", description: "Resize retained writable VFS entries", capabilities: ["retainedResize"], mutates: true }], async execute(context) {
return { name: "truncate", filesystemRequirements: [{ id: "resize", description: "Resize writable VFS entries through retained handles or atomic operations", capabilities: [], anyOf: [["retainedResize"], ["atomicResize"]], mutates: true }], async execute(context) {
context.signal.throwIfAborted();
const root = createOutputOperation({ signal: context.signal, registerCleanup(cleanup) {
let retirement: Promise<void> | undefined;
Expand Down Expand Up @@ -456,6 +456,18 @@ export function truncateCommand(options: MetadataCommandsOptions = {}): CommandD
if (readOnly === true) throw new FsError("EROFS");
const retainedResize = capabilities.retainedResize;
signal.throwIfAborted();
if (retainedResize !== true && capabilities.atomicResize === true) {
signal.throwIfAborted();
const resize = filesystem.resizeFile;
signal.throwIfAborted();
if (typeof resize !== "function") throw new FsError("ENOTSUP");
const operation: FileResizeOperation = { size: settings.size ?? reference!, modifier: settings.modifier,
...(reference === undefined ? {} : { referenceSize: reference }), ioBlocks: settings.blocks };
await root.acquire(signal => Reflect.apply(resize, filesystem, [path, operation,
{ create: !settings.noCreate, mode: 0o666 & ~configured.umask, signal }]), () => {});
signal.throwIfAborted();
continue;
}
if (retainedResize !== true) throw new FsError("ENOTSUP");
const openResizeFile = filesystem.openResizeFile;
signal.throwIfAborted();
Expand Down
2 changes: 1 addition & 1 deletion packages/safe-bash/src/contracts/filesystem.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { ACCESS_MODES } from "poe-code/safe-fs/core";
export type { FileType, EntryComparison, FileStat, DirectoryEntry, FileSystemCapabilities, FsOptions, CapabilityQueryOptions, RenameOptions, FileReadHandle, OpenReadFileOptions, FileResizeHandle, OpenResizeFileOptions, ReadFileOptions, ReadDirectoryOptions, WriteFileOptions, AppendFileOptions, MkdirOptions, RemoveOptions, CopyFileOptions, ReadStreamOptions, FileSystem, FileSystemFactory } from "poe-code/safe-fs/core";
export type { FileType, EntryComparison, FileStat, DirectoryEntry, FileSystemCapabilities, FsOptions, CapabilityQueryOptions, RenameOptions, FileReadHandle, OpenReadFileOptions, FileResizeHandle, FileResizeOperation, FileResizeOptions, OpenResizeFileOptions, ReadFileOptions, ReadDirectoryOptions, WriteFileOptions, AppendFileOptions, MkdirOptions, RemoveOptions, CopyFileOptions, ReadStreamOptions, FileSystem, FileSystemFactory } from "poe-code/safe-fs/core";
106 changes: 106 additions & 0 deletions packages/safe-bash/tests/commands/truncate-atomic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Shell } from "../../src/shell/index.js";
import { MemoryFileSystem } from "../../src/fs/memory/index.js";
import { truncateCommand } from "../../src/commands/truncate.js";
import { FsError, type FileResizeOperation, type FileResizeOptions } from "../../src/contracts/index.js";

function deferred() {
let resolve!: () => void;
const promise = new Promise<void>(complete => { resolve = complete; });
return { promise, resolve };
}

function fixture() {
const fs = new MemoryFileSystem();
Object.defineProperty(fs, "capabilities", { value: { ...fs.capabilities, retainedResize: false, atomicResize: true } });
const calls: { path: string; operation: FileResizeOperation; options: FileResizeOptions }[] = [];
const backend = Object.assign(fs, {
async resizeFile(path: string, operation: FileResizeOperation, options: FileResizeOptions = {}) {
options.signal?.throwIfAborted();
calls.push({path, operation, options});
},
});
const shell = new Shell({ fs: backend, cwd: "/" });
shell.register(truncateCommand());
return { backend, calls, shell };
}

for (const [argument, modifier, size] of [
["7", "absolute", 7n], ["+3", "relative", 3n], ["-9223372036854775808", "relative", -9223372036854775808n],
["<5", "maximum", 5n], [">8", "minimum", 8n], ["/4", "down", 4n], ["%4", "up", 4n],
] as const) test(`atomic resize receives exact ${argument} without resolving its final size`, async () => {
const { calls, shell } = fixture();
try {
const result = await shell.exec(`truncate -s '${argument}' /target`);
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(calls.length, 1);
assert.equal(calls[0]!.path, "/target");
assert.deepEqual(calls[0]!.operation, {size, modifier, ioBlocks: false});
assert.equal(calls[0]!.options.create, true);
assert.equal(calls[0]!.options.mode, 0o644);
} finally { await shell.dispose(); }
});

test("atomic resize passes one reference snapshot and block intent to the backend", async () => {
const { backend, calls, shell } = fixture();
await backend.writeFile("/reference", new TextEncoder().encode("1234567"));
try {
const result = await shell.exec("truncate -o -r /reference -s +2 /first /second");
assert.equal(result.exitCode, 0, result.stderr);
assert.equal(calls.length, 2);
assert.deepEqual(calls[0]!.operation, {size:2n, modifier:"relative", referenceSize:7n, ioBlocks:true});
assert.deepEqual(calls[1]!.operation, calls[0]!.operation);
} finally { await shell.dispose(); }
});

test("atomic no-create suppresses missing targets but reports other failures", async () => {
const { backend, shell } = fixture();
await backend.writeFile("/denied", new Uint8Array());
backend.resizeFile = async path => { throw new FsError(path === "/missing" ? "ENOENT" : "EACCES"); };
try {
assert.equal((await shell.exec("truncate -c -s 0 /missing")).exitCode, 0);
assert.equal((await shell.exec("truncate -c -s 0 /denied")).exitCode, 1);
} finally { await shell.dispose(); }
});

test("retained resize remains preferred when both contracts are available", async () => {
const fs = new MemoryFileSystem();
await fs.writeFile("/file", new TextEncoder().encode("abcdef"));
const backend = new Proxy(fs, {get(target,key) {
if(key === "capabilities") return {...target.capabilities,atomicResize:true};
if(key === "resizeFile") return async () => {throw new Error("atomic path must not replace retained semantics");};
const value=Reflect.get(target,key,target);
return typeof value === "function" ? value.bind(target) : value;
}});
const shell=new Shell({fs:backend,cwd:"/"}); shell.register(truncateCommand());
try {
assert.equal((await shell.exec("truncate -s 3 /file")).exitCode,0);
assert.equal(new TextDecoder().decode(await fs.readFile("/file")),"abc");
} finally {await shell.dispose();}
});

test("atomic resize refuses a capability without its method", async () => {
const { backend, shell }=fixture(); Reflect.deleteProperty(backend,"resizeFile");
try {assert.equal((await shell.exec("truncate -s 3 /file")).exitCode,1);}
finally {await shell.dispose();}
});

test("cancellation awaits the admitted atomic mutation before shell settlement", async () => {
const { backend,shell }=fixture(),controller=new AbortController();
const started=deferred(),release=deferred();
let observed:AbortSignal|undefined, settled=false;
backend.resizeFile=async (_path,_operation,options={})=>{
observed=options.signal; started.resolve(); await release.promise;
options.signal?.throwIfAborted();
};
const result=shell.exec("truncate -s 3 /file",{signal:controller.signal});
void result.then(()=>{settled=true;},()=>{settled=true;});
await started.promise;
const reason=new Error("cancel atomic resize"); controller.abort(reason);
await new Promise<void>(resolve=>setImmediate(resolve));
assert.equal(observed?.aborted,true); assert.equal(settled,false);
release.resolve();
await assert.rejects(result,error=>error===reason);
await shell.dispose();
});
4 changes: 2 additions & 2 deletions packages/safe-bash/tests/fs/readonly/readonly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,14 +325,14 @@ for (const readlink of [false, true]) {
test("capabilities are immutable, detached, conservative, and omit unknown extensions", () => {
const capabilities = {
readOnly: false, append: true, symlinks: true, hardlinks: true, permissions: true, timestamps: true,
atomicRename: true, streamingRead: true, streamingWrite: true, nativeExec: true, customWrites: true,
atomicRename: true, atomicResize: true, streamingRead: true, streamingWrite: true, nativeExec: true, customWrites: true,
};
const fixture = createFixture(true, capabilities);
const filesystem = createReadOnlyFileSystem(fixture.filesystem);
assert.deepEqual(filesystem.capabilities, {
readOnly: true, append: false, symlinks: true, hardlinks: false, permissions: false, timestamps: false,
atomicRename: false, atomicRenameNoReplace: false, streamingRead: true, streamingWrite: false, retainedRead: false,
descriptorWriteStream: false, retainedResize: false,
descriptorWriteStream: false, retainedResize: false, atomicResize: false,
write: false, exclusiveCreate: false, mkdir: false, recursiveMkdir: false,
remove: false, removeDirectory: false, recursiveRemove: false, rename: false,
copy: false, exclusiveCopy: false, truncate: false, streamingAppend: false, randomAccessWrite: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ import assert from "node:assert/strict";
import test from "node:test";
import { agentCommands, createAgentCommands, CommandRegistry, createMemoryFileSystem, createReadOnlyFileSystem, evaluateCommandSupport, Shell } from "../../src/index.js";

test("truncate support declaration requires retained resizing and respects readonly policy", () => {
test("truncate support declaration accepts retained or atomic resizing and respects readonly policy", () => {
const command = createAgentCommands().find(definition => definition.name === "truncate");
assert.ok(command);
for (const [capabilities, status] of [
[{ retainedResize: true }, "supported"],
[{}, "partial"],
[{ retainedResize: false }, "unsupported"],
[{ retainedResize: false }, "partial"],
[{ atomicResize: false }, "partial"],
[{ retainedResize: false, atomicResize: false }, "unsupported"],
[{ atomicResize: true }, "supported"],
[{ retainedResize: false, atomicResize: true }, "supported"],
[{ atomicResize: true, readOnly: true }, "unsupported"],
[{ retainedResize: true, readOnly: true }, "unsupported"],
] as const) {
const result = evaluateCommandSupport(command, capabilities);
Expand Down
34 changes: 34 additions & 0 deletions packages/safe-fs/src/contracts/filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,40 @@ writer's flags. Overlay declarations conservatively include upper staging and
copy-up prerequisites; missing required declarations remain unknown. Wrappers
must not manufacture support from delegated mandatory methods.

## Atomic resize operations

Backends that cannot retain a writable object across requests may instead offer
`resizeFile(path, operation, { create?, mode?, signal? })` and declare
`atomicResize: true`. This is one atomic target resolution, write-permission
check, size computation and resize transaction. It must never be implemented as
an unguarded pathname stat/read followed by a later whole-file write.

`operation.size` is a signed 64-bit bigint. `modifier` is `absolute`, `relative`,
`minimum` (at least), `maximum` (at most), `down` or `up` (round to a multiple).
Only `relative` accepts a negative operand; rounding requires a positive divisor.
`referenceSize`, when present, is a nonnegative signed 64-bit bigint snapshot
supplied by the caller and replaces the current target size as the modifier base.
`ioBlocks: true` multiplies the operand by the target's positive preferred I/O
block size before applying the modifier; missing block metadata is unsupported.
Signed overflow rejects, and a negative relative result clamps to zero. Providers
validate the final logical length and storage quotas before allocation or mutation.
The operation preserves the prefix and zero-fills extension. Refusals leave existing
bytes unchanged; write permission is checked even for unchanged lengths.

Creation defaults to false. Missing no-create targets reject with `ENOENT`;
creation does not create parents, and `mode` affects new files only. The operation
has a single linearization point against the target selected by the backend,
not retained-handle identity after rename or unlink. Cancellation does not undo a
committed resize; callers must await admitted work and must not replay blindly.

`truncate` keeps its retained-handle path when available and otherwise uses this
explicit atomic operation, passing the modifier rather than calculating from a
stale target size. Its reference is sampled once before processing targets.
Device and mount views forward supported operations, scoped views charge and
check cancellation, and readonly views deny them. Quota and overlay views do
not advertise atomic resize because their existing composition cannot preserve
its transaction guarantee. The retained resizing contract remains unchanged.

## Retained writable resizing

`openResizeFile(path, { create?, mode?, signal? })` is optional and requires
Expand Down
21 changes: 21 additions & 0 deletions packages/safe-fs/src/contracts/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface FileSystemCapabilities {
readonly streamingRead?: boolean;
readonly retainedRead?: boolean;
readonly retainedResize?: boolean;
readonly atomicResize?: boolean;
readonly streamingWrite?: boolean;
readonly descriptorWriteStream?: boolean;
readonly [capability: string]: boolean | undefined;
Expand Down Expand Up @@ -89,6 +90,24 @@ export interface OpenResizeFileOptions extends FsOptions {
readonly mode?: number;
}

/** A single atomic read/compute/resize operation, never a caller-side stat/write pair.
* size and referenceSize use signed 64-bit byte integers (referenceSize is nonnegative).
* ioBlocks scales size by the target's preferred I/O block size before applying the
* modifier. Relative/min/max/round operations use referenceSize or the current size.
* Signed overflow rejects; negative final sizes clamp to zero. Providers enforce
* their size/quota/permission limits before allocation or publication. */
export interface FileResizeOperation {
readonly size: bigint;
readonly modifier: "absolute" | "relative" | "minimum" | "maximum" | "down" | "up";
readonly referenceSize?: bigint;
readonly ioBlocks?: boolean;
}

export interface FileResizeOptions extends FsOptions {
readonly create?: boolean;
readonly mode?: number;
}

export interface FileResizeHandle {
stat(options?: FsOptions): Promise<FileStat>;
truncate(length: number, options?: FsOptions): Promise<void>;
Expand Down Expand Up @@ -148,6 +167,8 @@ export interface FileSystem {
readonly capabilities: FileSystemCapabilities;
openReadFile?(path: string, options?: OpenReadFileOptions): Promise<FileReadHandle>;
openResizeFile?(path: string, options?: OpenResizeFileOptions): Promise<FileResizeHandle>;
/** Missing targets reject ENOENT unless create is true; existing bytes survive refusals. */
resizeFile?(path: string, operation: FileResizeOperation, options?: FileResizeOptions): Promise<void>;
canonicalizeMissingTarget?(path: string, options?: FsOptions): string | undefined;
capabilitiesFor?(path: string, options?: CapabilityQueryOptions): Promise<FileSystemCapabilities>;
readFile(path: string, options?: ReadFileOptions): Promise<Uint8Array>;
Expand Down
4 changes: 2 additions & 2 deletions packages/safe-fs/src/fs/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export function readOnlyCapabilities(capabilities: FileSystemCapabilities): File
mkdir: false, recursiveMkdir: false, remove: false, removeDirectory: false, recursiveRemove: false,
rename: false, copy: false, exclusiveCopy: false, truncate: false, streamingAppend: false,
randomAccessWrite: false, hardlinks: false, permissions: false, timestamps: false,
descriptorWriteStream: false, retainedResize: false,
descriptorWriteStream: false, retainedResize: false, atomicResize: false,
atomicRename: false, atomicRenameNoReplace: false, streamingWrite: false,
});
}
Expand All @@ -116,7 +116,7 @@ export function quotaCapabilities(capabilities: FileSystemCapabilities): FileSys
const streamingWrite = requireCapabilities(capabilities.write, capabilities.append, !capabilities.readOnly);
const streamingAppend = requireCapabilities(capabilities.append, !capabilities.readOnly);
const { streamingWrite: ignoredWrite, streamingAppend: ignoredAppend, ...rest } = capabilities;
return Object.freeze({ ...rest, descriptorWriteStream: false,
return Object.freeze({ ...rest, descriptorWriteStream: false, atomicResize: false,
...(streamingWrite === undefined ? {} : { streamingWrite }),
...(streamingAppend === undefined ? {} : { streamingAppend }),
});
Expand Down
Loading
Loading