mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(filesystem): unify stable file identity checks
This commit is contained in:
1 parent
3e590e5b2f
commit
160648cc60
4 files changed
+81
-51
No files matched your search
@@ -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);
|
||||
};
|
||||
@@ -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) }
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user