fix(filesystem): unify stable file identity checks

This commit is contained in:
morluto committed 2026-10-27 12:45:57 +08:00
1 parent 3e590e5b2f
commit 160648cc60
4 files changed
+81 -51

No files matched your search

+30
View File
@@ -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<void> => {
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);
};
+6 -13
View File
@@ -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) }
+4 -37
View File
@@ -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<void> => {
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);
};
@@ -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");