diff --git a/src/filesystem/RegularFile.ts b/src/filesystem/RegularFile.ts index ce9b976f53..bc55f68fe4 100644 --- a/src/filesystem/RegularFile.ts +++ b/src/filesystem/RegularFile.ts @@ -207,3 +207,33 @@ export const sameRegularFileState = ( expected.ctimeNs === observed.ctimeNs : expected.mtimeMs === observed.mtimeMs && expected.ctimeMs === observed.ctimeMs); + +/** Validate an admitted descriptor and selected name at the same precision as admission. */ +export const assertStableRegularFileIdentity = async ( + file: FileHandle, + path: string, + before: Stats | BigIntStats, + options: { + readonly symlinks: "follow" | "reject"; + readonly signal?: AbortSignal; + }, +): Promise => { + assertRegularFileActive(options.signal); + const bigint = typeof before.dev === "bigint"; + const named = async () => { + try { + const lookup = options.symlinks === "follow" ? stat : lstat; + return bigint ? await lookup(path, { bigint: true }) : await lookup(path); + } catch (cause: unknown) { + if (isSelectedRegularFileError(cause)) + throw new RegularFileChangedError(path, { cause }); + throw cause; + } + }; + if ( + !sameRegularFileState(before, await file.stat({ bigint })) || + !sameRegularFileState(before, await named()) + ) + throw new RegularFileChangedError(path); + assertRegularFileActive(options.signal); +}; diff --git a/src/filesystem/StableFileHash.ts b/src/filesystem/StableFileHash.ts index e8f1fa8659..1fa92d4c7a 100644 --- a/src/filesystem/StableFileHash.ts +++ b/src/filesystem/StableFileHash.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; -import { stat } from "node:fs/promises"; import { withOwnedRegularFile, @@ -10,7 +9,7 @@ import { RegularFileChangedError, RegularFileOperationCancelled, assertRegularFileActive, - sameRegularFileState, + assertStableRegularFileIdentity, regularFileState, type RegularFileState, } from "./RegularFile.js"; @@ -122,17 +121,11 @@ async function readStableFile( position += read.bytesRead; } assertRegularFileActive(options.signal); - const [opened, currentPath] = await Promise.all([ - handle.stat(), - stat(path), - ]); - if ( - position !== initial.size || - !sameRegularFileState(initial, opened) || - !sameRegularFileState(initial, currentPath) - ) - throw new RegularFileChangedError(path); - assertRegularFileActive(options.signal); + if (position !== initial.size) throw new RegularFileChangedError(path); + await assertStableRegularFileIdentity(handle, path, initial, { + symlinks: "follow", + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); const fingerprint = { sha256: hash.digest("hex"), bytes: position }; return includePrefix ? { ...fingerprint, prefix, state: regularFileState(initial) } diff --git a/src/filesystem/readStableFileBytes.ts b/src/filesystem/readStableFileBytes.ts index 2798bcd3cb..a3bf6f6709 100644 --- a/src/filesystem/readStableFileBytes.ts +++ b/src/filesystem/readStableFileBytes.ts @@ -1,9 +1,8 @@ -import type { BigIntStats } from "node:fs"; -import { lstat, stat, type FileHandle } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; import { assertRegularFileActive, RegularFileChangedError, - sameRegularFileState, + assertStableRegularFileIdentity, } from "./RegularFile.js"; /** Resource refusal carries the observed extent before allocating file bytes. */ @@ -49,7 +48,7 @@ export const readStableFileBytes = async ( const extra = Buffer.alloc(1); if ((await file.read(extra, 0, 1, bytes.length)).bytesRead !== 0) throw new RegularFileChangedError(path); - await assertStableIdentity(file, path, before, options); + await assertStableRegularFileIdentity(file, path, before, options); return bytes; }; @@ -70,7 +69,7 @@ export const readStableFilePrefix = async ( before.size < BigInt(maximumBytes) ? before.size : BigInt(maximumBytes), ); const bytes = await readExtent(file, path, length, options.signal); - await assertStableIdentity(file, path, before, options); + await assertStableRegularFileIdentity(file, path, before, options); return { bytes, size: before.size }; }; @@ -97,35 +96,3 @@ const readExtent = async ( assertRegularFileActive(signal); return bytes; }; - -const assertStableIdentity = async ( - file: FileHandle, - path: string, - before: BigIntStats, - options: StableReadOptions, -): Promise => { - assertRegularFileActive(options.signal); - const named = async () => { - try { - return await (options.symlinks === "follow" ? stat : lstat)(path, { - bigint: true, - }); - } catch (cause: unknown) { - if ( - cause instanceof Error && - "code" in cause && - (cause.code === "ENOENT" || - cause.code === "ENOTDIR" || - cause.code === "ELOOP") - ) - throw new RegularFileChangedError(path, { cause }); - throw cause; - } - }; - if ( - !sameRegularFileState(before, await file.stat({ bigint: true })) || - !sameRegularFileState(before, await named()) - ) - throw new RegularFileChangedError(path); - assertRegularFileActive(options.signal); -}; diff --git a/tests/boundary/filesystem/stableFileHashOwnership.test.ts b/tests/boundary/filesystem/stableFileHashOwnership.test.ts index b4bb528fe2..56d2b60948 100644 --- a/tests/boundary/filesystem/stableFileHashOwnership.test.ts +++ b/tests/boundary/filesystem/stableFileHashOwnership.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { open as openFile, writeFile } from "node:fs/promises"; +import { open as openFile, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; @@ -52,6 +52,46 @@ it("rejects a real extent change after admission instead of replacing its baseli } }); +it("classifies a selected name removed at final validation as a changed file and closes its descriptor", async () => { + const root = await createTestTempDirectory("rea-stable-name-removed-"); + const path = join(root, "input.bin"); + await writeFile(path, "admitted bytes"); + const nativeOpen = regularFile.openRegularFile; + let descriptor: regularFile.StableRegularFileDescriptor | undefined; + const open = vi + .spyOn(regularFile, "openRegularFile") + .mockImplementationOnce(async (...args) => { + descriptor = await nativeOpen(...args); + const nativeStat = descriptor.handle.stat.bind(descriptor.handle); + vi.spyOn(descriptor.handle, "stat").mockImplementationOnce(async () => { + const metadata = await nativeStat(); + await unlink(path); + return metadata; + }); + return descriptor; + }); + try { + const retainFailedClose = vi.fn(); + const failure: unknown = await hashStableFile(path, { + retainFailedClose, + }).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + _tag: "StableFileHashFailure", + cause: { + name: "RegularFileChangedError", + code: "ESTALE", + cause: { code: "ENOENT" }, + }, + }); + expect(descriptor?.handle.fd).toBe(-1); + expect(retainFailedClose).not.toHaveBeenCalled(); + } finally { + open.mockRestore(); + if (descriptor !== undefined && descriptor.handle.fd >= 0) + await descriptor.handle.close(); + } +}); + it("captures a two-byte prefix across short reads and buffer reuse", async () => { const root = await createTestTempDirectory("rea-stable-observation-prefix-"); const path = join(root, "script");