diff --git a/src/domain/process/processCapture.validation.test.ts b/src/domain/process/processCapture.validation.test.ts index a24b983807..9a99f257d2 100644 --- a/src/domain/process/processCapture.validation.test.ts +++ b/src/domain/process/processCapture.validation.test.ts @@ -288,6 +288,75 @@ it("binds unfollowed coverage to observed symlink metadata without inventing an ).toThrow("enumeration_omissions"); }); +it("requires top-level filesystem facts to match their checkpoints", () => { + const baseline = emptyCapture(); + const file = { + path: "root_0:created.txt", + type: "file" as const, + mode: 0o100644, + size: 4, + sha256: "b".repeat(64), + symlink_target: null, + }; + const effect = { + path: file.path, + status: "created" as const, + before: null, + after: file, + }; + const topLevelOnly = { + ...baseline, + files_after: [file], + filesystem_effects: [effect], + truncation_details: { + ...baseline.truncation_details, + filesystem_after: { + ...baseline.truncation_details.filesystem_after, + hashed_bytes: 4, + }, + }, + }; + const consistent = { + ...topLevelOnly, + filesystem_checkpoints: baseline.filesystem_checkpoints.map((checkpoint) => + checkpoint.name === "after_settlement" + ? { ...checkpoint, files: [file], effects: [effect] } + : checkpoint, + ), + }; + expect(parseProcessCapture(consistent)).toEqual(consistent); + const withCheckpointFiles = ( + name: string, + files: (typeof baseline.files_before)[number][], + ) => + consistent.filesystem_checkpoints.map((checkpoint) => + checkpoint.name === name ? { ...checkpoint, files } : checkpoint, + ); + for (const contradictory of [ + topLevelOnly, + { ...consistent, filesystem_effects: [] }, + // File lists alone disagree; effects still match. + { + ...consistent, + filesystem_checkpoints: withCheckpointFiles("after_settlement", []), + }, + { + ...consistent, + filesystem_checkpoints: withCheckpointFiles("before", [file]), + }, + { + ...baseline, + filesystem_checkpoints: baseline.filesystem_checkpoints.map( + (checkpoint) => + checkpoint.name === "before" + ? { ...checkpoint, effects: [effect] } + : checkpoint, + ), + }, + ]) + expect(rejectedPaths(contradictory)).toEqual(["filesystem_checkpoints"]); +}); + it("never considers truncated captures equivalent", () => { const complete = emptyCapture(); const capture = { diff --git a/src/domain/process/processCaptureValidation.ts b/src/domain/process/processCaptureValidation.ts index b64ed1083e..fe6d08f3bd 100644 --- a/src/domain/process/processCaptureValidation.ts +++ b/src/domain/process/processCaptureValidation.ts @@ -1,3 +1,5 @@ +import canonicalize from "canonicalize"; + import { digestProcessCommitment, processScenarioOutputBudgetProjectionSchema, @@ -20,6 +22,11 @@ type RequireInvariant = ( message: string, ) => void; +const sameJsonValue = (left: unknown, right: unknown): boolean => { + const encoded = canonicalize(left); + return encoded !== undefined && encoded === canonicalize(right); +}; + const orderedTimestamps = (values: readonly number[]): boolean => values.every((value, index) => { const previous = values[index - 1]; @@ -254,6 +261,15 @@ const validateLifecycle = ( ), "filesystem_checkpoints", "filesystem snapshot times must be ordered"); require(!capture.filesystem_checkpoints.some(({ truncated }) => truncated) || capture.truncated, "truncated", "filesystem snapshot truncation must propagate to the capture"); + // Comparison reads only the checkpoints, so the top-level copies must not disagree. + const [initial, final] = capture.filesystem_checkpoints; + require(sameJsonValue(initial?.files, capture.files_before) && + initial?.effects.length === 0 && + sameJsonValue(final?.files, capture.files_after) && + sameJsonValue( + final?.effects, + capture.filesystem_effects, + ), "filesystem_checkpoints", "top-level filesystem snapshots and effects must match the initial and final checkpoints"); require(capture.exit.reason === "exited" || capture.exit.code === null, "exit", "deadline termination cannot declare a normal exit code");