diff --git a/packages/safe-bash/src/commands/truncate.ts b/packages/safe-bash/src/commands/truncate.ts index 4bea68eacd..ece89cd5ff 100644 --- a/packages/safe-bash/src/commands/truncate.ts +++ b/packages/safe-bash/src/commands/truncate.ts @@ -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"; @@ -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 | undefined; @@ -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(); diff --git a/packages/safe-bash/src/contracts/filesystem.ts b/packages/safe-bash/src/contracts/filesystem.ts index 2208bc6287..f307880264 100644 --- a/packages/safe-bash/src/contracts/filesystem.ts +++ b/packages/safe-bash/src/contracts/filesystem.ts @@ -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"; diff --git a/packages/safe-bash/tests/commands/truncate-atomic.test.ts b/packages/safe-bash/tests/commands/truncate-atomic.test.ts new file mode 100644 index 0000000000..f99ae08bbc --- /dev/null +++ b/packages/safe-bash/tests/commands/truncate-atomic.test.ts @@ -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(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(resolve=>setImmediate(resolve)); + assert.equal(observed?.aborted,true); assert.equal(settled,false); + release.resolve(); + await assert.rejects(result,error=>error===reason); + await shell.dispose(); +}); diff --git a/packages/safe-bash/tests/fs/readonly/readonly.test.ts b/packages/safe-bash/tests/fs/readonly/readonly.test.ts index 024f5c5a9b..6ccc6e7b2c 100644 --- a/packages/safe-bash/tests/fs/readonly/readonly.test.ts +++ b/packages/safe-bash/tests/fs/readonly/readonly.test.ts @@ -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, diff --git a/packages/safe-bash/tests/plugins/truncate-registration.test.ts b/packages/safe-bash/tests/plugins/truncate-registration.test.ts index 95067691c7..036d580633 100644 --- a/packages/safe-bash/tests/plugins/truncate-registration.test.ts +++ b/packages/safe-bash/tests/plugins/truncate-registration.test.ts @@ -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); diff --git a/packages/safe-fs/src/contracts/filesystem.md b/packages/safe-fs/src/contracts/filesystem.md index 86a75c9ef8..9f7a84300e 100644 --- a/packages/safe-fs/src/contracts/filesystem.md +++ b/packages/safe-fs/src/contracts/filesystem.md @@ -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 diff --git a/packages/safe-fs/src/contracts/filesystem.ts b/packages/safe-fs/src/contracts/filesystem.ts index f28841e193..20b6b23480 100644 --- a/packages/safe-fs/src/contracts/filesystem.ts +++ b/packages/safe-fs/src/contracts/filesystem.ts @@ -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; @@ -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; truncate(length: number, options?: FsOptions): Promise; @@ -148,6 +167,8 @@ export interface FileSystem { readonly capabilities: FileSystemCapabilities; openReadFile?(path: string, options?: OpenReadFileOptions): Promise; openResizeFile?(path: string, options?: OpenResizeFileOptions): Promise; + /** Missing targets reject ENOENT unless create is true; existing bytes survive refusals. */ + resizeFile?(path: string, operation: FileResizeOperation, options?: FileResizeOptions): Promise; canonicalizeMissingTarget?(path: string, options?: FsOptions): string | undefined; capabilitiesFor?(path: string, options?: CapabilityQueryOptions): Promise; readFile(path: string, options?: ReadFileOptions): Promise; diff --git a/packages/safe-fs/src/fs/capabilities.ts b/packages/safe-fs/src/fs/capabilities.ts index 7c2b776b62..85c7d4da5a 100644 --- a/packages/safe-fs/src/fs/capabilities.ts +++ b/packages/safe-fs/src/fs/capabilities.ts @@ -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, }); } @@ -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 }), }); diff --git a/packages/safe-fs/src/fs/devices/index.ts b/packages/safe-fs/src/fs/devices/index.ts index 684966b33c..eea33908ca 100644 --- a/packages/safe-fs/src/fs/devices/index.ts +++ b/packages/safe-fs/src/fs/devices/index.ts @@ -1,6 +1,6 @@ import { FsError, isFsError } from "../../contracts/errors.js"; import type { - AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, + AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, } from "../../contracts/filesystem.js"; @@ -20,14 +20,14 @@ const deviceCapabilities: FileSystemCapabilities = Object.freeze({ remove: false, removeDirectory: false, recursiveRemove: false, rename: false, mkdir: false, recursiveMkdir: false, symlinks: false, hardlinks: false, readlink: false, permissions: false, timestamps: false, truncate: false, randomAccessWrite: false, - atomicRename: false, atomicRenameNoReplace: false, descriptorWriteStream: true, retainedResize: true, + atomicRename: false, atomicRenameNoReplace: false, descriptorWriteStream: true, retainedResize: true, atomicResize: false, }); function globalCapabilities(filesystem: FileSystem): FileSystemCapabilities { const capabilities: Record = { readOnly: false }; const optional: Record = { streamingRead: ["readStream"], streamingWrite: ["writeStream"], retainedRead: ["openReadFile"], - streamingAppend: ["writeStream"], descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], + streamingAppend: ["writeStream"], descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], atomicResize: ["resizeFile"], symlinks: ["symlink", "readlink"], hardlinks: ["link"], permissions: ["chmod"], timestamps: ["utimes"], readlink: ["readlink"], truncate: ["truncate"], removeDirectory: ["rmdir"], }; @@ -136,6 +136,7 @@ export class DeviceFileSystem implements FileSystem { unavailable.descriptorWriteStream = false; } if (typeof this.#filesystem.openReadFile !== "function") unavailable.retainedRead = false; + if (observed.atomicResize === true && (typeof this.#filesystem.resizeFile !== "function" || observed.readOnly === true)) unavailable.atomicResize = false; const result = Object.keys(unavailable).some(key => capabilities[key] !== false) ? { ...capabilities, ...unavailable } : capabilities; options.signal?.throwIfAborted(); @@ -213,6 +214,18 @@ export class DeviceFileSystem implements FileSystem { }; } + async resizeFile(path: string, operation: FileResizeOperation, options: FileResizeOptions = {}): Promise { + await this.#mutable(path, options); + const capabilities = await this.capabilitiesFor(path, options); + options.signal?.throwIfAborted(); + if (capabilities.readOnly === true) throw new FsError("EROFS", { syscall: "resizeFile", path }); + const resize = this.#filesystem.resizeFile; + options.signal?.throwIfAborted(); + if (capabilities.atomicResize !== true || typeof resize !== "function") throw new FsError("ENOTSUP", { syscall: "resizeFile", path }); + await Reflect.apply(resize, this.#filesystem, [path, operation, options]); + options.signal?.throwIfAborted(); + } + async openResizeFile(path: string, options: OpenResizeFileOptions = {}): Promise { options.signal?.throwIfAborted(); const resolution = this.#resolve(path, options, true, options.create ?? false); diff --git a/packages/safe-fs/src/fs/mount/index.ts b/packages/safe-fs/src/fs/mount/index.ts index 03b26d1179..bc23f2e603 100644 --- a/packages/safe-fs/src/fs/mount/index.ts +++ b/packages/safe-fs/src/fs/mount/index.ts @@ -1,7 +1,7 @@ import { FsError, isFsError, toFsError } from "../../contracts/errors.js"; import type { ErrnoCode } from "../../contracts/errors.js"; import type { - AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, + AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, } from "../../contracts/filesystem.js"; @@ -136,7 +136,7 @@ export class MountFileSystem implements FileSystem { const common = (capability: string): boolean | undefined => { const optional: Record = { symlinks: ["symlink", "readlink"], hardlinks: ["link"], permissions: ["chmod"], timestamps: ["utimes"], readlink: ["readlink"], - descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], + descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], atomicResize: ["resizeFile"], }; const values = mounts.map(({ backend }) => { if (backend.capabilities.readOnly === true @@ -152,7 +152,7 @@ export class MountFileSystem implements FileSystem { "read", "stat", "readdir", "realpath", "access", "write", "append", "exclusiveCreate", "explicitDirectories", "implicitDirectories", "mkdir", "recursiveMkdir", "remove", "removeDirectory", "recursiveRemove", "rename", "atomicRenameNoReplace", "copy", "exclusiveCopy", "readlink", "truncate", - "streamingAppend", "randomAccessWrite", "descriptorWriteStream", "retainedResize", "symlinks", "hardlinks", "permissions", "timestamps", + "streamingAppend", "randomAccessWrite", "descriptorWriteStream", "retainedResize", "atomicResize", "symlinks", "hardlinks", "permissions", "timestamps", ].map(capability => [capability, common(capability)]).filter(([, value]) => value !== undefined)); this.capabilities = Object.freeze({ get snapshotRmdir() { return mounts.some(({ backend }) => backend.capabilities.snapshotRmdir === true); }, @@ -181,8 +181,10 @@ export class MountFileSystem implements FileSystem { const declared = observed.descriptorWriteStream === true && (typeof location.mount.backend.writeStream !== "function" || observed.readOnly === true || observed.streamingWrite === false) ? { ...observed, descriptorWriteStream: false } : observed; - const capabilities = location.synthetic ? { ...declared, retainedRead: false } - : retainedResizeCapabilities(location.mount.backend, retainedReadCapabilities(location.mount.backend, declared)); + const resize = declared.atomicResize === true && (typeof location.mount.backend.resizeFile !== "function" || declared.readOnly === true) + ? { ...declared, atomicResize: false } : declared; + const capabilities = location.synthetic ? { ...resize, retainedRead: false } + : retainedResizeCapabilities(location.mount.backend, retainedReadCapabilities(location.mount.backend, resize)); if (location.synthetic) return readOnlyCapabilities(capabilities); if (this.mounts.length === 1 || capabilities.readOnly === true) return Object.freeze({ ...capabilities }); const { rename: ignoredRename, copy: ignoredCopy, exclusiveCopy: ignoredExclusiveCopy, ...selected } = capabilities; @@ -210,6 +212,21 @@ export class MountFileSystem implements FileSystem { } } + async resizeFile(path: string, operation: FileResizeOperation, options: FileResizeOptions = {}): Promise { + return this.operation("resizeFile", path, options, async () => { + const location = await this.resolve(path, options, { allowMissing: options.create === true, resizeCreate: options.create ?? false }); + if (location.synthetic) fail("EROFS"); + const backend = location.mount.backend; + const capabilities = await backend.capabilitiesFor?.(location.local, options) ?? backend.capabilities; + options.signal?.throwIfAborted(); + if (capabilities.readOnly === true) fail("EROFS"); + const resize = backend.resizeFile; + options.signal?.throwIfAborted(); + if (capabilities.atomicResize !== true || typeof resize !== "function") fail("ENOTSUP"); + await Reflect.apply(resize!, backend, [location.local, operation, options]); + }); + } + async openResizeFile(path: string, options: OpenResizeFileOptions = {}): Promise { try { options.signal?.throwIfAborted(); diff --git a/packages/safe-fs/src/fs/overlay/index.ts b/packages/safe-fs/src/fs/overlay/index.ts index b863039d2b..ccc235c5e5 100644 --- a/packages/safe-fs/src/fs/overlay/index.ts +++ b/packages/safe-fs/src/fs/overlay/index.ts @@ -171,7 +171,7 @@ export class OverlayFileSystem implements FileSystem { ...(effectiveAppend === undefined ? {} : { append: effectiveAppend }), atomicRename: false, atomicRenameNoReplace: false, descriptorWriteStream: false, - retainedResize: false, + retainedResize: false, atomicResize: false, hardlinks: false, symlinks: writable && this.#upper.capabilities.symlinks === true && typeof this.#upper.symlink === "function" && typeof this.#upper.readlink === "function" diff --git a/packages/safe-fs/src/fs/quota/index.ts b/packages/safe-fs/src/fs/quota/index.ts index ff41fa8331..9b1ce9bd65 100644 --- a/packages/safe-fs/src/fs/quota/index.ts +++ b/packages/safe-fs/src/fs/quota/index.ts @@ -294,7 +294,7 @@ export function withFileSystemQuota(fs: FileSystem, options: FileSystemQuotaOpti // capabilities and methods without violating invariants on own properties. return new Proxy(Object.create(fs) as FileSystem, { get(_target, property) { - if (property === "canonicalizeMissingTarget") return undefined; + if (property === "canonicalizeMissingTarget" || property === "resizeFile") return undefined; if (property === "capabilities") return quotaCapabilities(retainedResizeCapabilities(fs, retainedReadCapabilities(fs))); if (property === "capabilitiesFor") return async (path: string, fsOptions?: FsOptions) => { const capabilities = await fs.capabilitiesFor?.(path, fsOptions) ?? fs.capabilities; diff --git a/packages/safe-fs/src/fs/scoped.ts b/packages/safe-fs/src/fs/scoped.ts index dabe4ccf7b..310ffa4352 100644 --- a/packages/safe-fs/src/fs/scoped.ts +++ b/packages/safe-fs/src/fs/scoped.ts @@ -9,7 +9,7 @@ const originals = new WeakMap(); const operations = new Set([ "access", "appendFile", "canonicalizeMissingTarget", "capabilitiesFor", "chmod", "compareEntry", "copyFile", "link", "lstat", "mkdir", "openReadFile", "openResizeFile", "readFile", "readStream", "readdir", - "readlink", "realpath", "rename", "rm", "rmdir", "stat", "symlink", "truncate", "utimes", + "readlink", "realpath", "rename", "resizeFile", "rm", "rmdir", "stat", "symlink", "truncate", "utimes", "writeFile", "writeStream", ]); diff --git a/packages/safe-fs/tests/atomic-resize.test.ts b/packages/safe-fs/tests/atomic-resize.test.ts new file mode 100644 index 0000000000..99bad192c1 --- /dev/null +++ b/packages/safe-fs/tests/atomic-resize.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { MemoryFileSystem } from "../src/fs/memory/index.js"; +import { createDeviceFileSystem } from "../src/fs/devices/index.js"; +import { createMountFileSystem } from "../src/fs/mount/index.js"; +import { createReadOnlyFileSystem } from "../src/fs/readonly/index.js"; +import { withFileSystemQuota } from "../src/fs/quota/index.js"; +import { scopeFileSystem } from "../src/fs/scoped.js"; +import type { FileResizeOperation, FileResizeOptions } from "../src/contracts/filesystem.js"; + +function fixture() { + const fs = new MemoryFileSystem(); + Object.defineProperty(fs, "capabilities", {value:{...fs.capabilities,retainedResize:false,atomicResize:true}}); + return Object.assign(fs,{resizeFile:vi.fn(async (_path:string,_operation:FileResizeOperation,_options:FileResizeOptions={})=>{})}); +} +const operation = {size:2n,modifier:"relative",ioBlocks:false} as const; +describe("atomic resize adapter boundaries", () => { + it("device view forwards the operation and signals without target-size reads", async () => { + const backend=fixture(), fs=createDeviceFileSystem(backend); + const options={create:true,mode:0o600,signal:new AbortController().signal}; + expect((await fs.capabilitiesFor("/target",options)).atomicResize).toBe(true); + await fs.resizeFile!("/target",operation,options); + expect(backend.resizeFile).toHaveBeenCalledExactlyOnceWith("/target",operation,options); + }); + it("mounts route atomic operations without rewriting their size intent", async () => { + const backend=fixture(), fs=createMountFileSystem({root:new MemoryFileSystem(),mounts:{"/data":backend}}); + const options={create:true}; + await fs.resizeFile("/data/target",operation,options); + expect(backend.resizeFile).toHaveBeenCalledExactlyOnceWith("/target",operation,options); + }); + it("device capabilities revoke an absent resize method", async () => { + const backend=fixture(); Reflect.deleteProperty(backend,"resizeFile"); + expect((await createDeviceFileSystem(backend).capabilitiesFor("/target")).atomicResize).toBe(false); + }); + it("readonly and quota wrappers do not expose an unguarded atomic mutation", () => { + const backend=fixture(); + const readonly=createReadOnlyFileSystem(backend); + const quota=withFileSystemQuota(backend,{maxBytes:1024}); + expect(readonly.capabilities.atomicResize).toBe(false); + expect(quota.capabilities.atomicResize).toBe(false); + expect(quota.resizeFile).toBeUndefined(); + }); + it("scoped atomic resize charges once and cannot run after cancellation", async () => { + const backend=fixture(), controller=new AbortController(), charge=vi.fn(); + const fs=scopeFileSystem(backend,charge,controller.signal); + await fs.resizeFile!("/target",operation); + expect(charge).toHaveBeenCalledTimes(1); + controller.abort(new Error("closed")); + await expect(fs.resizeFile!("/target",operation)).rejects.toThrow("closed"); + expect(backend.resizeFile).toHaveBeenCalledTimes(1); + }); +});