diff --git a/src/application/CrossVersionInvestigation.ts b/src/application/CrossVersionInvestigation.ts index f714652f8..9ed402b07 100644 --- a/src/application/CrossVersionInvestigation.ts +++ b/src/application/CrossVersionInvestigation.ts @@ -13,6 +13,7 @@ import { type AnalysisError, } from "../domain/errors.js"; import { + createLegacyInvestigationRunIdentity, createInvestigationRunIdentity, createInvestigationWorkspace, crossVersionInvestigationInputSchema, @@ -137,8 +138,20 @@ const continueInvestigation = async (context: { integrity_continue_approved: context.input.integrity_continue_approved, max_integrity_mismatches: context.input.max_integrity_mismatches, }); + const legacyIdentity = createLegacyInvestigationRunIdentity({ + left, + right, + options: context.input.options, + }); const existing = context.initial?.runs.find( - ({ run_id: id }) => id === identity.runId, + (run) => + run.run_id === identity.runId || + (context.input.integrity_policy === "fail" && + !context.input.integrity_continue_approved && + context.input.max_integrity_mismatches === 10 && + "legacy_request_identity" in run && + run.max_integrity_mismatches === 10 && + run.run_id === legacyIdentity.runId), ); if (context.initial !== null && existing?.status === "complete") return completeOutcome(context.initial, existing, true, context.session); diff --git a/src/domain/investigationWorkspace.ts b/src/domain/investigationWorkspace.ts index b41f48ddf..5845f4c09 100644 --- a/src/domain/investigationWorkspace.ts +++ b/src/domain/investigationWorkspace.ts @@ -100,7 +100,7 @@ const stageSchema = z.enum([ "find_changed_behavior", ]); -export const investigationRunSchema = z.object({ +const investigationRunBaseSchema = z.object({ schema_version: z.literal(1), run_id: z.string().regex(/^run_[a-f0-9]{64}$/u), request_sha256: digestSchema, @@ -119,6 +119,31 @@ export const investigationRunSchema = z.object({ limitations: z.array(z.string().max(4_096)).max(100), }); +export const investigationRunSchema = z + .union([ + investigationRunBaseSchema.extend({ + legacy_request_identity: z.literal(true), + }), + investigationRunBaseSchema, + ]) + .superRefine((run, context) => { + if (!("legacy_request_identity" in run)) return; + const legacyIdentity = createLegacyInvestigationRunIdentity(run); + if ( + run.integrity_policy !== "fail" || + run.integrity_continue_approved || + run.max_integrity_mismatches !== 10 || + run.run_id !== legacyIdentity.runId || + run.request_sha256 !== legacyIdentity.requestSha256 + ) + context.addIssue({ + code: "custom", + path: ["legacy_request_identity"], + message: + "Legacy request identity is valid only for migrated strict runs", + }); + }); + export type InvestigationRun = z.infer; export const investigationRunSummarySchema = z.object({ @@ -146,6 +171,20 @@ const investigationWorkspaceSchema = z.object({ runs: z.array(investigationRunSchema).max(1_000), }); +const legacyInvestigationRunSchema = investigationRunBaseSchema + .omit({ + integrity_policy: true, + integrity_continue_approved: true, + max_integrity_mismatches: true, + }) + .strict(); + +const legacyInvestigationWorkspaceSchema = investigationWorkspaceSchema + .extend({ + runs: z.array(legacyInvestigationRunSchema).max(1_000), + }) + .strict(); + export type InvestigationWorkspace = z.infer< typeof investigationWorkspaceSchema >; @@ -207,6 +246,7 @@ export const reviseInvestigationWorkspace = ( export const parseInvestigationWorkspace = ( input: unknown, ): InvestigationWorkspace => { + if (isLegacyWorkspace(input)) return migrateLegacyWorkspace(input); const parsed = investigationWorkspaceSchema.parse(input); const bundle = parseEvidenceBundle(parsed.bundle); const canonical = buildWorkspace({ @@ -268,9 +308,16 @@ const validateRunReferences = (workspace: InvestigationWorkspace): void => { ); for (const run of workspace.runs) { const expectedIdentity = createInvestigationRunIdentity(run); + const legacyIdentity = createLegacyInvestigationRunIdentity(run); if ( - run.run_id !== expectedIdentity.runId || - run.request_sha256 !== expectedIdentity.requestSha256 + (run.run_id !== expectedIdentity.runId || + run.request_sha256 !== expectedIdentity.requestSha256) && + (!("legacy_request_identity" in run) || + run.integrity_policy !== "fail" || + run.integrity_continue_approved || + run.max_integrity_mismatches !== 10 || + run.run_id !== legacyIdentity.runId || + run.request_sha256 !== legacyIdentity.requestSha256) ) throw new TypeError("Investigation run identity is invalid"); validateRunStages(run); @@ -287,6 +334,85 @@ const validateRunReferences = (workspace: InvestigationWorkspace): void => { } }; +/** Derive the pre-integrity-policy run identity for strict workspace migration. */ +export const createLegacyInvestigationRunIdentity = (input: { + readonly left: InvestigationRunTarget; + readonly right: InvestigationRunTarget; + readonly options: InvestigationRunOptions; +}): { readonly runId: string; readonly requestSha256: string } => { + const requestSha256 = digestCanonical({ + schema: "rea.cross-version-investigation-request/v1", + left: input.left, + right: input.right, + options: input.options, + }); + return { runId: `run_${requestSha256}`, requestSha256 }; +}; + +const isLegacyWorkspace = (input: unknown): boolean => { + if (typeof input !== "object" || input === null || !("runs" in input)) + return false; + const runs = input.runs; + return ( + Array.isArray(runs) && + runs.some( + (run) => + typeof run === "object" && run !== null && !("integrity_policy" in run), + ) + ); +}; + +const migrateLegacyWorkspace = (input: unknown): InvestigationWorkspace => { + const parsed = legacyInvestigationWorkspaceSchema.parse(input); + const bundle = parseEvidenceBundle(parsed.bundle); + const runs = [...parsed.runs].sort((left, right) => + left.run_id.localeCompare(right.run_id), + ); + const semantic = { + workspace_version: 1, + workspace_id: parsed.workspace_id, + name: parsed.name, + revision: parsed.revision, + previous_revision_digest: parsed.previous_revision_digest, + bundle, + runs, + } satisfies JsonValue; + if (parsed.workspace_id !== workspaceIdFor(parsed.name)) + throw new TypeError("Investigation workspace ID does not match its name"); + if (parsed.revision_digest !== `wrev_${digestCanonical(semantic)}`) + throw new TypeError("Investigation workspace revision digest is invalid"); + const canonical = legacyInvestigationWorkspaceSchema.parse({ + ...semantic, + revision_digest: parsed.revision_digest, + }); + if (JSON.stringify(parsed) !== JSON.stringify(canonical)) + throw new TypeError("Investigation workspace is not canonical"); + for (const run of runs) { + const identity = createLegacyInvestigationRunIdentity(run); + if ( + run.run_id !== identity.runId || + run.request_sha256 !== identity.requestSha256 + ) + throw new TypeError("Investigation run identity is invalid"); + } + const migrated = buildWorkspace({ + workspaceId: parsed.workspace_id, + name: parsed.name, + revision: parsed.revision + 1, + previousRevisionDigest: parsed.revision_digest, + bundle, + runs: runs.map((run) => ({ + ...run, + legacy_request_identity: true as const, + integrity_policy: "fail" as const, + integrity_continue_approved: false, + max_integrity_mismatches: 10, + })), + }); + validateRunReferences(migrated); + return migrated; +}; + const validateRunStages = (run: InvestigationRun): void => { const expected = [ "inventory_left", diff --git a/tests/investigationWorkspace.test.ts b/tests/investigationWorkspace.test.ts index 8a5741ba9..cdf1c7625 100644 --- a/tests/investigationWorkspace.test.ts +++ b/tests/investigationWorkspace.test.ts @@ -8,11 +8,13 @@ import { symlink, writeFile, } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createPackageWithOptions } from "@electron/asar"; +import canonicalize from "canonicalize"; import { runCrossVersionInvestigation } from "../src/application/CrossVersionInvestigation.js"; import { @@ -26,9 +28,16 @@ import { createInvestigationWorkspace, crossVersionInvestigationInputSchema, investigationRunSchema, + parseInvestigationWorkspace, serializeInvestigationWorkspace, } from "../src/domain/investigationWorkspace.js"; +const digestCanonical = (value: unknown): string => { + const encoded = canonicalize(value); + if (encoded === undefined) throw new TypeError("fixture is not canonical"); + return createHash("sha256").update(encoded).digest("hex"); +}; + let directory: string | undefined; afterEach(async () => { @@ -68,6 +77,116 @@ const fixture = async () => { }; describe("persistent cross-version investigation workspace", () => { + it("migrates legacy v1 runs without losing their revision chain", async () => { + const { input } = await fixture(); + if (directory === undefined) throw new Error("missing fixture root"); + const completed = await runCrossVersionInvestigation( + input, + policy(directory), + { inputRoots: [directory] }, + ); + if (!completed.ok) throw completed.error; + const current = completed.value.workspace; + const currentRun = current.runs[0]; + if (currentRun === undefined) throw new Error("missing completed run"); + expect(() => + investigationRunSchema.parse({ + ...currentRun, + legacy_request_identity: true, + }), + ).toThrow(/migrated strict runs/iu); + const legacyIdentity = digestCanonical({ + schema: "rea.cross-version-investigation-request/v1", + left: currentRun.left, + right: currentRun.right, + options: currentRun.options, + }); + expect(() => + investigationRunSchema.parse({ + ...currentRun, + legacy_request_identity: true, + run_id: `run_${legacyIdentity}`, + request_sha256: legacyIdentity, + max_integrity_mismatches: 100, + }), + ).toThrow(/migrated strict runs/iu); + const legacyRuns = current.runs.map((run) => { + const requestSha256 = digestCanonical({ + schema: "rea.cross-version-investigation-request/v1", + left: run.left, + right: run.right, + options: run.options, + }); + const { + integrity_policy: _integrityPolicy, + integrity_continue_approved: _integrityApproved, + max_integrity_mismatches: _integrityLimit, + ...legacy + } = run; + return { + ...legacy, + run_id: `run_${requestSha256}`, + request_sha256: requestSha256, + }; + }); + const legacySemantic = { + workspace_version: 1 as const, + workspace_id: current.workspace_id, + name: current.name, + revision: current.revision, + previous_revision_digest: current.previous_revision_digest, + bundle: current.bundle, + runs: legacyRuns, + }; + const legacyDigest = `wrev_${digestCanonical(legacySemantic)}`; + expect(() => + parseInvestigationWorkspace({ + ...legacySemantic, + runs: legacyRuns.map((run) => ({ + ...run, + max_integrity_mismatches: 100, + })), + revision_digest: legacyDigest, + }), + ).toThrow(/unrecognized key/iu); + const migrated = parseInvestigationWorkspace({ + ...legacySemantic, + revision_digest: legacyDigest, + }); + + expect(migrated).toMatchObject({ + revision: current.revision + 1, + previous_revision_digest: legacyDigest, + runs: [ + { + legacy_request_identity: true, + integrity_policy: "fail", + integrity_continue_approved: false, + max_integrity_mismatches: 10, + }, + ], + }); + const encodedLegacy = canonicalize({ + ...legacySemantic, + revision_digest: legacyDigest, + }); + if (encodedLegacy === undefined) + throw new TypeError("legacy fixture is not canonical"); + await writeFile(input.workspace_path, encodedLegacy, { mode: 0o600 }); + await expect( + runCrossVersionInvestigation(input, policy(directory), { + inputRoots: [directory], + }), + ).resolves.toMatchObject({ ok: true, value: { reused: true } }); + await expect( + runCrossVersionInvestigation( + { ...input, integrity_continue_approved: true }, + policy(directory), + { inputRoots: [directory] }, + ), + ).resolves.toMatchObject({ ok: true, value: { reused: false } }); + }); + it("checkpoints, validates, and reuses a completed deterministic run", async () => { const { path, input } = await fixture(); if (directory === undefined) throw new Error("missing fixture root");