mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +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
@@ -143,8 +143,10 @@ Every capture requires `truncation_details`, with separate accounting for:
|
|||||||
- `filesystem_before` and `filesystem_after`: file-count/depth limits,
|
- `filesystem_before` and `filesystem_after`: file-count/depth limits,
|
||||||
enumeration failures, whole-file hash budget and bytes successfully hashed.
|
enumeration failures, whole-file hash budget and bytes successfully hashed.
|
||||||
Each retained regular file without a digest has an aliased path, size,
|
Each retained regular file without a digest has an aliased path, size,
|
||||||
remaining budget and reason: `file_bytes_budget` or
|
remaining budget and reason: `file_bytes_budget`,
|
||||||
`file_changed_or_short_read`. A file too large for the remaining budget is
|
`file_changed_or_short_read`, or `file_unavailable`. Its `system_code` is
|
||||||
|
null unless an OS file operation failed, in which case it preserves the
|
||||||
|
reported error code. A file too large for the remaining budget is
|
||||||
skipped; a later smaller file can still be hashed. Hash omissions do not
|
skipped; a later smaller file can still be hashed. Hash omissions do not
|
||||||
imply incomplete path enumeration.
|
imply incomplete path enumeration.
|
||||||
- `process`: sampling limit and whether sampling ended partially. Coverage
|
- `process`: sampling limit and whether sampling ended partially. Coverage
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ export const filesystemCoverageSchema = z.strictObject({
|
|||||||
path: z.string(),
|
path: z.string(),
|
||||||
size_bytes: z.number().int().nonnegative(),
|
size_bytes: z.number().int().nonnegative(),
|
||||||
remaining_budget_bytes: z.number().int().nonnegative(),
|
remaining_budget_bytes: z.number().int().nonnegative(),
|
||||||
reason: z.enum(["file_bytes_budget", "file_changed_or_short_read"]),
|
reason: z.enum([
|
||||||
|
"file_bytes_budget",
|
||||||
|
"file_changed_or_short_read",
|
||||||
|
"file_unavailable",
|
||||||
|
]),
|
||||||
|
system_code: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -200,7 +200,10 @@ const validateCoverage = (
|
|||||||
coverage.hash_omissions.every(
|
coverage.hash_omissions.every(
|
||||||
(omission) =>
|
(omission) =>
|
||||||
unhashedFiles.get(omission.path)?.size === omission.size_bytes &&
|
unhashedFiles.get(omission.path)?.size === omission.size_bytes &&
|
||||||
omission.remaining_budget_bytes <= coverage.hash_budget_bytes,
|
omission.remaining_budget_bytes <= coverage.hash_budget_bytes &&
|
||||||
|
(omission.reason === "file_unavailable"
|
||||||
|
? omission.system_code !== null
|
||||||
|
: omission.system_code === null),
|
||||||
), `truncation_details.${name}.hash_omissions`, "every retained regular file without a digest must have one matching omission reason");
|
), `truncation_details.${name}.hash_omissions`, "every retained regular file without a digest must have one matching omission reason");
|
||||||
require(coverage.hashed_bytes ===
|
require(coverage.hashed_bytes ===
|
||||||
files
|
files
|
||||||
|
|||||||
@@ -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 { join } from "node:path";
|
||||||
import { expect, it } from "vitest";
|
import { expect, it } from "vitest";
|
||||||
import { parseProcessScenario } from "../../domain/process/processScenario.js";
|
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 });
|
).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 () => {
|
it("preserves creation when the selected root was observed missing before capture", async () => {
|
||||||
const root = await createTestTempDirectory("rea-fs-missing-root-");
|
const root = await createTestTempDirectory("rea-fs-missing-root-");
|
||||||
const file = join(root, "created.txt");
|
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 rm(path);
|
||||||
await writeFile(path, "replacement with a different identity and size\n");
|
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 {
|
} finally {
|
||||||
await rm(directory, { recursive: true, force: true });
|
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);
|
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 {
|
} finally {
|
||||||
await rm(directory, { recursive: true, force: true });
|
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
|
// hashFile reaches `open` synchronously before its first suspension, so
|
||||||
// this abort is observed after the descriptor is acquired and is handled
|
// this abort is observed after the descriptor is acquired and is handled
|
||||||
// by the `finally` close path.
|
// 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);
|
expect(await countOpenDescriptorsFor(path)).toBe(0);
|
||||||
} finally {
|
} finally {
|
||||||
await rm(directory, { recursive: true, force: true });
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { constants as fsConstants } from "node:fs";
|
import { constants as fsConstants } from "node:fs";
|
||||||
import { lstat, open, readdir, readlink } from "node:fs/promises";
|
import { lstat, open, readdir, readlink } from "node:fs/promises";
|
||||||
import { dirname, join, relative, resolve } from "node:path";
|
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 { ProcessScenario } from "../../domain/process/processScenario.js";
|
||||||
import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js";
|
import type { FilesystemCoverage } from "../../domain/process/processCaptureCoverage.js";
|
||||||
import type { Stats } from "node:fs";
|
import type { Stats } from "node:fs";
|
||||||
@@ -29,6 +30,23 @@ const hasSameFileState = (before: Stats, after: Stats): boolean =>
|
|||||||
before.mtimeMs === after.mtimeMs &&
|
before.mtimeMs === after.mtimeMs &&
|
||||||
before.ctimeMs === after.ctimeMs;
|
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> => {
|
const lstatIfPresent = async (path: string): Promise<Stats | undefined> => {
|
||||||
try {
|
try {
|
||||||
return await lstat(path);
|
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. */
|
/** Hash a file only when its opened descriptor still matches the captured path state. */
|
||||||
export const hashFile = async (
|
export const hashFile = async (
|
||||||
path: string,
|
path: string,
|
||||||
expected: Stats,
|
expected: Stats,
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<string | null> => {
|
): Promise<FileHashOutcome> => {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const handle = await open(
|
let handle: FileHandle;
|
||||||
path,
|
try {
|
||||||
fsConstants.O_RDONLY |
|
handle = await open(
|
||||||
(fsConstants.O_NOFOLLOW ?? 0) |
|
path,
|
||||||
(fsConstants.O_NONBLOCK ?? 0),
|
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 {
|
try {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const stats = await handle.stat();
|
const stats = await handle.stat();
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (!hasSameFileState(expected, stats) || stats.size > maxBytes)
|
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 hash = createHash("sha256");
|
||||||
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes));
|
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes));
|
||||||
let position = 0;
|
let position = 0;
|
||||||
@@ -70,14 +113,33 @@ export const hashFile = async (
|
|||||||
Math.min(buffer.length, stats.size - position),
|
Math.min(buffer.length, stats.size - position),
|
||||||
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));
|
hash.update(buffer.subarray(0, bytesRead));
|
||||||
position += bytesRead;
|
position += bytesRead;
|
||||||
}
|
}
|
||||||
const after = await handle.stat();
|
const after = await handle.stat();
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (!hasSameFileState(stats, after)) return null;
|
if (!hasSameFileState(stats, after))
|
||||||
return hash.digest("hex");
|
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 {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
@@ -136,22 +198,34 @@ export const snapshotRoots = async (
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (stats.isFile()) {
|
if (stats.isFile()) {
|
||||||
const sha256 =
|
let sha256: string | null = null;
|
||||||
remainingBytes >= stats.size
|
let omission: FilesystemCoverage["hash_omissions"][number] | undefined;
|
||||||
? await hashFile(path, stats, remainingBytes, signal)
|
if (remainingBytes < stats.size) {
|
||||||
: null;
|
omission = {
|
||||||
remainingBytes -= sha256 === null ? 0 : stats.size;
|
|
||||||
if (sha256 === null) {
|
|
||||||
truncated = true;
|
|
||||||
hashOmissions.push({
|
|
||||||
path: `${rootAlias}:${relativePath}`,
|
path: `${rootAlias}:${relativePath}`,
|
||||||
size_bytes: stats.size,
|
size_bytes: stats.size,
|
||||||
remaining_budget_bytes: remainingBytes,
|
remaining_budget_bytes: remainingBytes,
|
||||||
reason:
|
reason: "file_bytes_budget",
|
||||||
remainingBytes < stats.size
|
system_code: null,
|
||||||
? "file_bytes_budget"
|
};
|
||||||
: "file_changed_or_short_read",
|
} 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({
|
entries.push({
|
||||||
path: `${rootAlias}:${relativePath}`,
|
path: `${rootAlias}:${relativePath}`,
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)(
|
|||||||
size_bytes: 8,
|
size_bytes: 8,
|
||||||
remaining_budget_bytes: 1,
|
remaining_budget_bytes: 1,
|
||||||
reason: "file_bytes_budget",
|
reason: "file_bytes_budget",
|
||||||
|
system_code: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -229,6 +230,7 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)(
|
|||||||
path: "root_0:b.txt",
|
path: "root_0:b.txt",
|
||||||
reason: "file_bytes_budget",
|
reason: "file_bytes_budget",
|
||||||
remaining_budget_bytes: 1,
|
remaining_budget_bytes: 1,
|
||||||
|
system_code: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
+9
-2
@@ -18,6 +18,13 @@ execFileSync("mkfifo", [path]);
|
|||||||
// This invokes the production lstat-to-open hashing seam with a known captured
|
// This invokes the production lstat-to-open hashing seam with a known captured
|
||||||
// regular-file identity. The owner test bounds this child so an O_RDONLY FIFO
|
// regular-file identity. The owner test bounds this child so an O_RDONLY FIFO
|
||||||
// regression cannot hang Vitest.
|
// regression cannot hang Vitest.
|
||||||
const digest = await hashFile(path, expected, 1_000);
|
const outcome = await hashFile(path, expected, 1_000);
|
||||||
if (digest !== null) throw new Error("Expected the FIFO to be rejected");
|
if (
|
||||||
|
outcome.state !== "omitted" ||
|
||||||
|
outcome.reason !== "file_changed_or_short_read" ||
|
||||||
|
outcome.system_code !== null
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Expected the FIFO to be omitted as a changed file: ${JSON.stringify(outcome)}`,
|
||||||
|
);
|
||||||
process.stdout.write("rejected\n");
|
process.stdout.write("rejected\n");
|
||||||
Reference in new issue
Block a user