mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 18:22:42 +00:00
fix(process)!: retain per-file digest open failures
BREAKING CHANGE: Filesystem digest omission entries now require system_code; budget and changed-file omissions use null, and unavailable-file omissions preserve the operating-system error code.
This commit is contained in:
1 parent
f62b44971d
commit
415145d04f
8 files changed
+202
-35
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { parseProcessScenario } from "../../domain/process/processScenario.js";
|
||||
@@ -81,6 +81,51 @@ it("distinguishes unavailable content hashes from completed path enumeration", a
|
||||
).toMatchObject({ status: "deleted", before: { sha256: null }, after: null });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"retains unreadable-file and sibling observations in a complete snapshot",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-fs-unreadable-file-");
|
||||
const unreadable = join(root, "unreadable.txt");
|
||||
const readable = join(root, "readable.txt");
|
||||
await writeFile(unreadable, "permission denied");
|
||||
await writeFile(readable, "still observed");
|
||||
await chmod(unreadable, 0);
|
||||
const scenario = parseProcessScenario({
|
||||
executable: process.execPath,
|
||||
working_directory: root,
|
||||
filesystem_observation_paths: [root],
|
||||
});
|
||||
|
||||
try {
|
||||
const snapshot = await snapshotRoots(scenario);
|
||||
expect(snapshot.files.map(({ path }) => path)).toEqual([
|
||||
"root_0:.",
|
||||
"root_0:readable.txt",
|
||||
"root_0:unreadable.txt",
|
||||
]);
|
||||
expect(
|
||||
snapshot.files.find(({ path }) => path === "root_0:readable.txt")
|
||||
?.sha256,
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
snapshot.files.find(({ path }) => path === "root_0:unreadable.txt"),
|
||||
).toMatchObject({ type: "file", size: 17, sha256: null });
|
||||
expect(snapshot.coverage.hash_omissions).toContainEqual({
|
||||
path: "root_0:unreadable.txt",
|
||||
size_bytes: 17,
|
||||
remaining_budget_bytes:
|
||||
scenario.limits.file_bytes - Buffer.byteLength("still observed"),
|
||||
reason: "file_unavailable",
|
||||
system_code: "EACCES",
|
||||
});
|
||||
expect(snapshot.completeRoots).toEqual(["root_0"]);
|
||||
} finally {
|
||||
await chmod(unreadable, 0o600);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves creation when the selected root was observed missing before capture", async () => {
|
||||
const root = await createTestTempDirectory("rea-fs-missing-root-");
|
||||
const file = join(root, "created.txt");
|
||||
|
||||
@@ -44,7 +44,11 @@ it("does not hash a replacement file using the earlier path metadata", async ()
|
||||
await rm(path);
|
||||
await writeFile(path, "replacement with a different identity and size\n");
|
||||
|
||||
await expect(hashFile(path, expected, 1_000)).resolves.toBeNull();
|
||||
await expect(hashFile(path, expected, 1_000)).resolves.toEqual({
|
||||
state: "omitted",
|
||||
reason: "file_changed_or_short_read",
|
||||
system_code: null,
|
||||
});
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -76,7 +80,29 @@ it("rejects a same-size rewrite even when its modification time is restored", as
|
||||
});
|
||||
expect(rewritten.ctimeMs).not.toBe(expected.ctimeMs);
|
||||
|
||||
await expect(hashFile(path, expected, 1_000)).resolves.toBeNull();
|
||||
await expect(hashFile(path, expected, 1_000)).resolves.toMatchObject({
|
||||
state: "omitted",
|
||||
reason: "file_changed_or_short_read",
|
||||
system_code: null,
|
||||
});
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a file removed between metadata observation and open", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "rea-snapshot-open-race-"));
|
||||
const path = join(directory, "observed-file");
|
||||
try {
|
||||
await writeFile(path, "captured file\n");
|
||||
const expected = await lstat(path);
|
||||
await rm(path);
|
||||
|
||||
await expect(hashFile(path, expected, 1_000)).resolves.toEqual({
|
||||
state: "omitted",
|
||||
reason: "file_unavailable",
|
||||
system_code: "ENOENT",
|
||||
});
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -116,9 +142,12 @@ it.skipIf(process.platform === "win32")(
|
||||
// hashFile reaches `open` synchronously before its first suspension, so
|
||||
// this abort is observed after the descriptor is acquired and is handled
|
||||
// by the `finally` close path.
|
||||
controller.abort();
|
||||
const reason = Object.assign(new Error("cancelled while opening"), {
|
||||
code: "EACCES",
|
||||
});
|
||||
controller.abort(reason);
|
||||
|
||||
await expect(hashing).rejects.toMatchObject({ name: "AbortError" });
|
||||
await expect(hashing).rejects.toBe(reason);
|
||||
expect(await countOpenDescriptorsFor(path)).toBe(0);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { lstat, open, readdir, readlink } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import type { ProcessScenario } from "../../domain/process/processScenario.js";
|
||||
import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js";
|
||||
import type { Stats } from "node:fs";
|
||||
@@ -29,6 +30,23 @@ const hasSameFileState = (before: Stats, after: Stats): boolean =>
|
||||
before.mtimeMs === after.mtimeMs &&
|
||||
before.ctimeMs === after.ctimeMs;
|
||||
|
||||
const fileUnavailableSystemCode = (cause: unknown): string | undefined => {
|
||||
if (!(cause instanceof Error) || !("code" in cause)) return undefined;
|
||||
const code = cause.code;
|
||||
return typeof code === "string" &&
|
||||
[
|
||||
"EACCES",
|
||||
"EISDIR",
|
||||
"ELOOP",
|
||||
"ENOTDIR",
|
||||
"ENOENT",
|
||||
"EPERM",
|
||||
"ESTALE",
|
||||
].includes(code)
|
||||
? code
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const lstatIfPresent = async (path: string): Promise<Stats | undefined> => {
|
||||
try {
|
||||
return await lstat(path);
|
||||
@@ -39,26 +57,51 @@ const lstatIfPresent = async (path: string): Promise<Stats | undefined> => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Reports a complete digest or an explicit per-file reason hashing was omitted. */
|
||||
export type FileHashOutcome =
|
||||
| { readonly state: "hashed"; readonly sha256: string }
|
||||
| {
|
||||
readonly state: "omitted";
|
||||
readonly reason: "file_changed_or_short_read" | "file_unavailable";
|
||||
readonly system_code: string | null;
|
||||
};
|
||||
|
||||
/** Hash a file only when its opened descriptor still matches the captured path state. */
|
||||
export const hashFile = async (
|
||||
path: string,
|
||||
expected: Stats,
|
||||
maxBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | null> => {
|
||||
): Promise<FileHashOutcome> => {
|
||||
signal?.throwIfAborted();
|
||||
const handle = await open(
|
||||
path,
|
||||
fsConstants.O_RDONLY |
|
||||
(fsConstants.O_NOFOLLOW ?? 0) |
|
||||
(fsConstants.O_NONBLOCK ?? 0),
|
||||
);
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await open(
|
||||
path,
|
||||
fsConstants.O_RDONLY |
|
||||
(fsConstants.O_NOFOLLOW ?? 0) |
|
||||
(fsConstants.O_NONBLOCK ?? 0),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
const systemCode = fileUnavailableSystemCode(cause);
|
||||
if (systemCode === undefined) throw cause;
|
||||
return {
|
||||
state: "omitted",
|
||||
reason: "file_unavailable",
|
||||
system_code: systemCode,
|
||||
};
|
||||
}
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const stats = await handle.stat();
|
||||
signal?.throwIfAborted();
|
||||
if (!hasSameFileState(expected, stats) || stats.size > maxBytes)
|
||||
return null;
|
||||
return {
|
||||
state: "omitted",
|
||||
reason: "file_changed_or_short_read",
|
||||
system_code: null,
|
||||
};
|
||||
const hash = createHash("sha256");
|
||||
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes));
|
||||
let position = 0;
|
||||
@@ -70,14 +113,33 @@ export const hashFile = async (
|
||||
Math.min(buffer.length, stats.size - position),
|
||||
position,
|
||||
);
|
||||
if (bytesRead === 0) return null;
|
||||
if (bytesRead === 0)
|
||||
return {
|
||||
state: "omitted",
|
||||
reason: "file_changed_or_short_read",
|
||||
system_code: null,
|
||||
};
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
position += bytesRead;
|
||||
}
|
||||
const after = await handle.stat();
|
||||
signal?.throwIfAborted();
|
||||
if (!hasSameFileState(stats, after)) return null;
|
||||
return hash.digest("hex");
|
||||
if (!hasSameFileState(stats, after))
|
||||
return {
|
||||
state: "omitted",
|
||||
reason: "file_changed_or_short_read",
|
||||
system_code: null,
|
||||
};
|
||||
return { state: "hashed", sha256: hash.digest("hex") };
|
||||
} catch (cause: unknown) {
|
||||
signal?.throwIfAborted();
|
||||
const systemCode = fileUnavailableSystemCode(cause);
|
||||
if (systemCode === undefined) throw cause;
|
||||
return {
|
||||
state: "omitted",
|
||||
reason: "file_unavailable",
|
||||
system_code: systemCode,
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
@@ -136,22 +198,34 @@ export const snapshotRoots = async (
|
||||
return true;
|
||||
}
|
||||
if (stats.isFile()) {
|
||||
const sha256 =
|
||||
remainingBytes >= stats.size
|
||||
? await hashFile(path, stats, remainingBytes, signal)
|
||||
: null;
|
||||
remainingBytes -= sha256 === null ? 0 : stats.size;
|
||||
if (sha256 === null) {
|
||||
truncated = true;
|
||||
hashOmissions.push({
|
||||
let sha256: string | null = null;
|
||||
let omission: FilesystemCoverage["hash_omissions"][number] | undefined;
|
||||
if (remainingBytes < stats.size) {
|
||||
omission = {
|
||||
path: `${rootAlias}:${relativePath}`,
|
||||
size_bytes: stats.size,
|
||||
remaining_budget_bytes: remainingBytes,
|
||||
reason:
|
||||
remainingBytes < stats.size
|
||||
? "file_bytes_budget"
|
||||
: "file_changed_or_short_read",
|
||||
});
|
||||
reason: "file_bytes_budget",
|
||||
system_code: null,
|
||||
};
|
||||
} else {
|
||||
const outcome = await hashFile(path, stats, remainingBytes, signal);
|
||||
if (outcome.state === "hashed") {
|
||||
sha256 = outcome.sha256;
|
||||
remainingBytes -= stats.size;
|
||||
} else {
|
||||
omission = {
|
||||
path: `${rootAlias}:${relativePath}`,
|
||||
size_bytes: stats.size,
|
||||
remaining_budget_bytes: remainingBytes,
|
||||
reason: outcome.reason,
|
||||
system_code: outcome.system_code,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (omission !== undefined) {
|
||||
truncated = true;
|
||||
hashOmissions.push(omission);
|
||||
}
|
||||
entries.push({
|
||||
path: `${rootAlias}:${relativePath}`,
|
||||
|
||||
Reference in new issue
Block a user