fix(process): keep unobserved file contents unknown (#1358)

This commit is contained in:
You Wish 2026-10-09 18:36:35 -04:00 committed by GitHub
parent 037709a52c
commit f91f2798b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 267 additions and 35 deletions

View File

@ -1,4 +1,11 @@
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import {
chmod,
mkdir,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { parseProcessScenario } from "../../domain/process/processScenario.js";
@ -126,6 +133,161 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
},
);
it.each([
{ name: "both", beforeOmitted: true, afterOmitted: true, contents: "after!" },
{
name: "before",
beforeOmitted: true,
afterOmitted: false,
contents: "after!",
},
{
name: "after",
beforeOmitted: false,
afterOmitted: true,
contents: "after!",
},
{
name: "both for an untouched file",
beforeOmitted: true,
afterOmitted: true,
contents: "before",
},
])(
"keeps content equality unknown when $name snapshots omit its digest",
async ({ beforeOmitted, afterOmitted, contents }) => {
const root = await createTestTempDirectory("rea-fs-content-unknown-");
const budgetFile = join(root, "a.txt");
const file = join(root, "b.txt");
await writeFile(file, "before");
if (beforeOmitted) await writeFile(budgetFile, "budget");
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [root],
limits: { file_bytes: 6 },
});
const before = await snapshotRoots(scenario);
if (beforeOmitted && !afterOmitted) await rm(budgetFile);
if (!beforeOmitted && afterOmitted) await writeFile(budgetFile, "budget");
if (contents !== "before") await writeFile(file, contents);
const after = await snapshotRoots(scenario);
expect(await readFile(file, "utf8")).toBe(contents);
const effect = classifyFilesystemEffects(before, after).find(
({ path }) => path === "root_0:b.txt",
);
expect(effect).toMatchObject({
status: "unknown",
before: {
type: "file",
size: 6,
sha256: beforeOmitted ? null : expect.any(String),
},
after: {
type: "file",
size: 6,
sha256: afterOmitted ? null : expect.any(String),
},
reason: expect.stringMatching(/content.*hash|hash.*content/iu),
});
},
);
it("preserves observed content and metadata changes without treating missing hashes as equality", async () => {
const root = await createTestTempDirectory("rea-fs-observed-changes-");
for (const name of ["changed", "stable", "unhashed", "retyped"])
await writeFile(join(root, name), "before");
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [root],
});
const before = await snapshotRoots(scenario);
await writeFile(join(root, "changed"), "after!");
await writeFile(join(root, "unhashed"), "different size");
await rm(join(root, "retyped"));
await mkdir(join(root, "retyped"));
const after = await snapshotRoots(scenario);
const effects = classifyFilesystemEffects(before, after);
expect(effects.find(({ path }) => path === "root_0:changed")?.status).toBe(
"modified",
);
expect(effects.find(({ path }) => path === "root_0:stable")?.status).toBe(
"unchanged",
);
expect(effects.find(({ path }) => path === "root_0:retyped")?.status).toBe(
"modified",
);
const noHashes = parseProcessScenario({
...scenario,
limits: { file_bytes: 1 },
});
const unhashedBefore = await snapshotRoots(noHashes);
await writeFile(join(root, "unhashed"), "size changed again");
const unhashedAfter = await snapshotRoots(noHashes);
expect(
classifyFilesystemEffects(unhashedBefore, unhashedAfter).find(
({ path }) => path === "root_0:unhashed",
),
).toMatchObject({
status: "modified",
before: { sha256: null },
after: { sha256: null },
});
});
it.skipIf(process.platform === "win32")(
"preserves an observed permission change when file digests are omitted",
async () => {
const root = await createTestTempDirectory("rea-fs-mode-change-");
const file = join(root, "file");
await writeFile(file, "before", { mode: 0o600 });
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [file],
limits: { file_bytes: 1 },
});
const before = await snapshotRoots(scenario);
await chmod(file, 0o644);
const after = await snapshotRoots(scenario);
expect(classifyFilesystemEffects(before, after)).toMatchObject([
{ status: "modified", before: { sha256: null }, after: { sha256: null } },
]);
},
);
it.skipIf(process.platform === "win32")(
"compares directories and symlink targets without requiring file content hashes",
async () => {
const root = await createTestTempDirectory("rea-fs-nonfile-effects-");
await mkdir(join(root, "empty"));
await writeFile(join(root, "a.txt"), "before");
await writeFile(join(root, "b.txt"), "unused");
await symlink("a.txt", join(root, "stable-link"));
await symlink("a.txt", join(root, "moved-link"));
const scenario = parseProcessScenario({
executable: process.execPath,
working_directory: root,
filesystem_observation_paths: [root],
limits: { file_bytes: 1 },
});
const before = await snapshotRoots(scenario);
await writeFile(join(root, "a.txt"), "after!");
await rm(join(root, "moved-link"));
await symlink("b.txt", join(root, "moved-link"));
const after = await snapshotRoots(scenario);
const effects = classifyFilesystemEffects(before, after);
for (const name of ["empty", "stable-link"])
expect(
effects.find(({ path }) => path === `root_0:${name}`)?.status,
).toBe("unchanged");
expect(
effects.find(({ path }) => path === "root_0:moved-link")?.status,
).toBe("modified");
},
);
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");

View File

@ -250,11 +250,13 @@ export const buildCaptureResult = (
options.before,
options.after,
);
const hasUnknownFilesystemEffects = filesystemEffects.some(
({ status }) => status === "unknown",
);
const incompleteFilesystemUnknown =
"Path absence in incomplete enumeration or below an unfollowed symbolic link remains unknown.";
const filesystemUnknowns = [
...new Set(
filesystemEffects.flatMap((effect) =>
effect.status === "unknown" ? [effect.reason] : [],
),
),
];
return {
manifest: options.manifest,
normalization: options.scenario.normalization,
@ -291,7 +293,7 @@ export const buildCaptureResult = (
"Filesystem observations are before/after snapshots, not syscall traces.",
"Inherited host environment variables are not recorded and may affect results.",
...(!hasFilesystemObservations ? [filesystemObservationUnknown] : []),
...(hasUnknownFilesystemEffects ? [incompleteFilesystemUnknown] : []),
...filesystemUnknowns,
...captureCoverageUnknowns(options.truncationDetails).map(
({ reason }) => reason,
),
@ -324,14 +326,10 @@ export const buildCaptureResult = (
},
]
: []),
...(hasUnknownFilesystemEffects
? [
{
scope: "filesystem" as const,
reason: incompleteFilesystemUnknown,
},
]
: []),
...filesystemUnknowns.map((reason) => ({
scope: "filesystem" as const,
reason,
})),
...(hasSensitiveScriptedInput
? [{ scope: "interaction" as const, reason: sensitiveInputUnknown }]
: []),

View File

@ -29,7 +29,7 @@ const unobservedAbsenceReason = (
return undefined;
};
/** Classify effects only when a snapshot establishes the missing path's absence. */
/** Classify effects only from observed path absence, metadata, and content hashes. */
export const classifyFilesystemEffects = (
before: ProcessFilesystemSnapshot,
after: ProcessFilesystemSnapshot,
@ -84,12 +84,31 @@ export const classifyFilesystemEffects = (
before: beforeFile,
after: null,
} as const;
const metadataChanged =
beforeFile.type !== afterFile.type ||
beforeFile.mode !== afterFile.mode ||
beforeFile.size !== afterFile.size ||
beforeFile.symlink_target !== afterFile.symlink_target;
if (
!metadataChanged &&
beforeFile.type === "file" &&
afterFile.type === "file" &&
(beforeFile.sha256 === null || afterFile.sha256 === null)
)
return {
path,
status: "unknown",
before: beforeFile,
after: afterFile,
reason:
"A file content hash is unavailable in at least one snapshot; matching metadata does not establish unchanged contents.",
} as const;
return {
path,
status:
JSON.stringify(beforeFile) === JSON.stringify(afterFile)
? "unchanged"
: "modified",
metadataChanged || beforeFile.sha256 !== afterFile.sha256
? "modified"
: "unchanged",
before: beforeFile,
after: afterFile,
} as const;

View File

@ -17,6 +17,22 @@ import {
const exec = promisify(execFile);
const captureViaCli = async (scenario: Record<string, unknown>) => {
const scenarioPath = join(
await createTestTempDirectory("rea-fs-scenario-"),
"scenario.json",
);
await writeFile(scenarioPath, JSON.stringify(scenario));
const { stdout } = await exec(process.execPath, [
"scripts/rea.mjs",
"capture-process",
scenarioPath,
"--format",
"json",
]);
return parseEvidence(JSON.parse(stdout));
};
const captureViaMcp = async (scenario: Record<string, unknown>) => {
const session = createTestBinarySession(() => {
throw new Error("Process capture must not launch a binary provider");
@ -54,22 +70,9 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)(
filesystem_observation_paths: [root],
limits: { files: 2 },
};
let evidence;
if (adapter === "cli") {
const scenarioPath = join(
await createTestTempDirectory("rea-fs-scenario-"),
"scenario.json",
);
await writeFile(scenarioPath, JSON.stringify(scenario));
const { stdout } = await exec(process.execPath, [
"scripts/rea.mjs",
"capture-process",
scenarioPath,
"--format",
"json",
]);
evidence = parseEvidence(JSON.parse(stdout));
} else evidence = await captureViaMcp(scenario);
const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)(
scenario,
);
const capture = parseProcessCapture(evidence.normalized_result);
expect(capture.truncated).toBe(true);
expect(await readFile(join(root, "z.txt"), "utf8")).toBe("unchanged");
@ -86,6 +89,56 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)(
20_000,
);
itWithCaptureCapability.each(["cli", "mcp"] as const)(
"reports unknown content after a same-size rewrite without digests through %s",
async (adapter) => {
const root = await createTestTempDirectory("rea-fs-content-adapter-");
const file = join(root, "changed.txt");
await writeFile(file, "before");
const scenario = {
executable: process.execPath,
arguments: [
"-e",
"require('node:fs').writeFileSync('changed.txt','after!')",
],
working_directory: root,
filesystem_observation_paths: [root],
limits: { file_bytes: 1 },
};
const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)(
scenario,
);
const capture = parseProcessCapture(evidence.normalized_result);
expect(await readFile(file, "utf8")).toBe("after!");
const effect = capture.filesystem_effects.find(
({ path }) => path === "root_0:changed.txt",
);
expect(effect).toMatchObject({
status: "unknown",
reason: expect.any(String),
before: { size: 6, sha256: null },
after: { size: 6, sha256: null },
});
if (effect?.status !== "unknown")
throw new Error("Expected unknown file contents");
expect(
capture.filesystem_checkpoints.find(
({ name }) => name === "after_settlement",
)?.effects,
).toContainEqual(effect);
expect(capture.residual_unknowns).toContainEqual({
scope: "filesystem",
reason: effect.reason,
});
expect(capture.limitations).toContain(effect.reason);
expect(
capture.truncation_details.filesystem_after.enumeration_truncated,
).toBe(false);
expect(compareProcessCaptures(capture, capture).filesystem).toBe("unknown");
},
20_000,
);
it.skipIf(CAPTURE_SKIP_REASON || process.platform === "win32")(
"keeps unfollowed descendant absence unknown through MCP Evidence and comparison",
async () => {