fix(process): reject contradictory filesystem capture facts (#7631)
* fix(process): reject captures whose top-level filesystem facts contradict their checkpoints A process capture records its filesystem state twice: files_before, files_after and filesystem_effects at the top level, and the initial and final filesystem_checkpoints. Validation never reconciled the two, and comparison reads only the checkpoints. A caller-authored capture recording a created file at the top level while its final checkpoint was empty was therefore accepted and compared as unchanged. Semantic validation now requires the initial checkpoint to match files_before with no effects, and the final checkpoint to match files_after and filesystem_effects. The native producer builds both representations from the same snapshots, so produced captures are unaffected; consistent created-file captures still compare as changed. Fixes #7371 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(process): cover file-list checkpoint mismatches independently of effects Every rejected case in the checkpoint-consistency test also disagreed on effects, so removing both file-list comparisons left it passing. Add initial and final checkpoint cases whose file lists alone disagree while effects and coverage accounting stay valid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e1094313f5
commit
25923d372c
2 files changed
+85
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user