fix(process): keep unobserved file contents unknown (#1358)
This commit is contained in:
parent
037709a52c
commit
f91f2798b4
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 }]
|
||||
: []),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue