From 50822e1eff99136c5e5f7b1b1d3351c898937983 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:59:32 +0800 Subject: [PATCH] fix(evidence): include every subject format in bundles --- src/domain/analysisSnapshot.ts | 8 +-- src/domain/evidence.ts | 8 +-- src/domain/evidenceBundle.test.ts | 30 +++++++++ src/domain/evidenceBundle.ts | 19 +++--- src/domain/evidenceSubject.test.ts | 8 +++ src/domain/evidenceSubject.ts | 47 +++++++++----- .../boundary/mcp/evidenceBundleInline.test.ts | 64 +++++++++++++++++++ 7 files changed, 144 insertions(+), 40 deletions(-) diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 892341873b..65b457187f 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; import { z } from "zod"; -import { evidenceSubjectDeclarationShapes } from "./evidenceSubject.js"; +import { withEvidenceSubjectDeclaration } from "./evidenceSubject.js"; import { compareUnicodeCodePoints, @@ -85,11 +85,7 @@ const subjectBaseSchema = z.object({ path: localPathStringSchema, sha256: digestSchema, }); -const subjectSchema = z.discriminatedUnion("format", [ - subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[0]), - subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[1]), - subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[2]), -]); +const subjectSchema = withEvidenceSubjectDeclaration(subjectBaseSchema); const targetIdentitySchema = z.object({ sha256: digestSchema, kind: kindSchema, diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 38a42dd7f4..82a82d47ec 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -1,7 +1,7 @@ import { basename } from "node:path"; import { z } from "zod"; -import { evidenceSubjectDeclarationShapes } from "./evidenceSubject.js"; +import { withEvidenceSubjectDeclaration } from "./evidenceSubject.js"; import { digestCanonicalValue } from "./canonicalDigest.js"; import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js"; @@ -43,11 +43,7 @@ const subjectBaseSchema = z.strictObject({ digest: z.strictObject({ sha256: digestSchema }), local_path: nulFreeStringSchema, }); -const subjectSchema = z.discriminatedUnion("format", [ - subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[0]), - subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[1]), - subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[2]), -]); +const subjectSchema = withEvidenceSubjectDeclaration(subjectBaseSchema); /** Source location attached to an evidence observation. */ /** Source-location schema shared by evidence-bearing persistence formats. */ export const evidenceLocationSchema = z.discriminatedUnion("kind", [ diff --git a/src/domain/evidenceBundle.test.ts b/src/domain/evidenceBundle.test.ts index 44c78f29b0..dab9ce9295 100644 --- a/src/domain/evidenceBundle.test.ts +++ b/src/domain/evidenceBundle.test.ts @@ -20,6 +20,36 @@ const provider = { id: "fixture", name: "Fixture", version: "1" }; const targetDigest = "a".repeat(64); const foreignDigest = "b".repeat(64); +describe("artifact manifest projection", () => { + it("round-trips GameCube DOL Evidence through retained bundles and target selection", () => { + const record = createEvidence( + { + path: "/tmp/game.dol", + sha256: targetDigest, + format: "gamecube-dol", + architecture: "powerpc", + }, + provider, + { + operation: "inspect_artifact", + parameters: {}, + result: { observed: true }, + }, + ); + const bundle = createImmutableEvidenceBundle([record]); + expect(bundle.artifacts).toEqual([ + { + digest: { sha256: targetDigest }, + format: "gamecube-dol", + architecture: "powerpc", + }, + ]); + const restored = parseEvidenceBundle(JSON.parse(JSON.stringify(bundle))); + expect(restored.records[0]?.evidence_id).toBe(record.evidence_id); + expect(evidenceBundleForTarget(restored, targetDigest)).toEqual(bundle); + }); +}); + describe("evidence bundle failure classification", () => { it("tags valid-shaped bundles that fail canonical ordering", () => { const records = ["first", "second"].map((label) => diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index 82b7146fdb..be3dfcfd63 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -1,3 +1,5 @@ +import { withEvidenceSubjectDeclaration } from "./evidenceSubject.js"; +import { digestSchema } from "./digests.js"; import { EvidenceIntegrityError } from "./evidenceErrors.js"; import { err, ok, type Result } from "./result.js"; import { describeValidationFailure } from "./validationFailure.js"; @@ -20,17 +22,12 @@ import { } from "./residualUnknown.js"; import { freezeJsonSnapshot } from "./immutableJson.js"; -const [detectedSubjectSchema, rawSubjectSchema] = - evidenceSchema.shape.subject.unwrap().options; -const manifestFields = { - digest: true, - format: true, - architecture: true, -} as const; -const artifactManifestSchema = z.discriminatedUnion("format", [ - detectedSubjectSchema.pick(manifestFields), - rawSubjectSchema.pick(manifestFields), -]); +const artifactManifestBaseSchema = z.strictObject({ + digest: z.strictObject({ sha256: digestSchema }), +}); +const artifactManifestSchema = withEvidenceSubjectDeclaration( + artifactManifestBaseSchema, +); const providerManifestSchema = providerSchema; const environmentManifestSchema = evidenceSchema.shape.environment.unwrap(); const scenarioManifestSchema = z.strictObject({ diff --git a/src/domain/evidenceSubject.test.ts b/src/domain/evidenceSubject.test.ts index 9caa3fcfb2..03140e829f 100644 --- a/src/domain/evidenceSubject.test.ts +++ b/src/domain/evidenceSubject.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { analysisSnapshotSchema } from "./analysisSnapshot.js"; import { BINARY_ARCHITECTURES } from "./binaryTargetTypes.js"; +import { evidenceBundleSchema } from "./evidenceBundle.js"; import { evidenceSchema } from "./evidence.js"; import { evidenceSubjectFormatSchema } from "./evidenceSubject.js"; @@ -38,6 +39,13 @@ describe("persisted observation subject declarations", () => { (architecture !== "6502" && architecture !== "m68k")); expect(evidence.success, JSON.stringify(declaration)).toBe(allowed); expect(snapshot.success, JSON.stringify(declaration)).toBe(allowed); + const manifest = evidenceBundleSchema.shape.artifacts.element.safeParse( + { + ...declaration, + digest: { sha256: digest }, + }, + ); + expect(manifest.success, JSON.stringify(declaration)).toBe(allowed); } } }); diff --git a/src/domain/evidenceSubject.ts b/src/domain/evidenceSubject.ts index a4bd2fadfb..0df7cb33e6 100644 --- a/src/domain/evidenceSubject.ts +++ b/src/domain/evidenceSubject.ts @@ -41,20 +41,33 @@ export const evidenceSubjectFormatSchema = z.enum([ ]); /** Format-owned architecture declarations shared by Evidence and persisted executions. */ -export const evidenceSubjectDeclarationShapes = [ - { - format: evidenceSubjectFormatSchema.exclude(["raw-binary", "gamecube-dol"]), - architecture: z - .enum(BINARY_ARCHITECTURES) - .exclude([...RAW_ONLY_ARCHITECTURES]) - .nullable(), - }, - { - format: z.literal("raw-binary"), - architecture: z.enum(BINARY_ARCHITECTURES).nullable(), - }, - { - format: z.literal("gamecube-dol"), - architecture: z.literal("powerpc"), - }, -] as const; +const detectedEvidenceSubjectDeclaration = { + format: evidenceSubjectFormatSchema.exclude(["raw-binary", "gamecube-dol"]), + architecture: z + .enum(BINARY_ARCHITECTURES) + .exclude([...RAW_ONLY_ARCHITECTURES]) + .nullable(), +}; + +const rawEvidenceSubjectDeclaration = { + format: z.literal("raw-binary"), + architecture: z.enum(BINARY_ARCHITECTURES).nullable(), +}; + +const gamecubeEvidenceSubjectDeclaration = { + format: z.literal("gamecube-dol"), + architecture: z.literal("powerpc"), +}; + +/** Add the complete format-owned declaration to a persistence envelope. */ +export const withEvidenceSubjectDeclaration = < + Shape extends z.ZodRawShape, + Config extends z.core.$ZodObjectConfig, +>( + base: z.ZodObject, +) => + z.discriminatedUnion("format", [ + base.extend(detectedEvidenceSubjectDeclaration), + base.extend(rawEvidenceSubjectDeclaration), + base.extend(gamecubeEvidenceSubjectDeclaration), + ]); diff --git a/tests/boundary/mcp/evidenceBundleInline.test.ts b/tests/boundary/mcp/evidenceBundleInline.test.ts index 060a1687a5..453e1a71a2 100644 --- a/tests/boundary/mcp/evidenceBundleInline.test.ts +++ b/tests/boundary/mcp/evidenceBundleInline.test.ts @@ -18,6 +18,70 @@ import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidencePro import { createServer } from "../../../src/server/createServer.js"; import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js"; +it("exports and imports GameCube DOL Evidence without losing its subject identity", async () => { + const root = await createTestTempDirectory("rea-dol-bundle-mcp-"); + const path = join(root, "bundle.json"); + const record = createEvidence( + { + path: join(root, "game.dol"), + sha256: "d".repeat(64), + format: "gamecube-dol", + architecture: "powerpc", + }, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "inspect_artifact", + parameters: {}, + result: { observed: true }, + }, + ); + const session = createTestBinarySession(() => { + throw new Error("bundle round trip must not run a provider"); + }); + expect(session.recordEvidence(record).ok).toBe(true); + const source = await connectEvidenceMcp("dol-source", session); + const importedSession = createTestBinarySession(() => { + throw new Error("bundle import must not run a provider"); + }); + const target = await connectEvidenceMcp("dol-target", importedSession); + try { + const inline = await source.client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect(inline.isError).not.toBe(true); + expect(inline.structuredContent).toMatchObject({ + result: { + artifacts: [{ format: "gamecube-dol", architecture: "powerpc" }], + }, + }); + const exported = await source.client.callTool({ + name: "export_evidence_bundle", + arguments: { path }, + }); + expect(exported.isError).not.toBe(true); + const imported = await target.client.callTool({ + name: "import_evidence_bundle", + arguments: { path }, + }); + expect(imported.isError).not.toBe(true); + const restored = await target.client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }); + expect(restored.structuredContent).toEqual(inline.structuredContent); + } finally { + await Promise.allSettled([ + source.client.close(), + source.server.close(), + session.close(), + target.client.close(), + target.server.close(), + importedSession.close(), + ]); + } +}); + it("preserves BOM recovery through public bundle import", async () => { const root = await createTestTempDirectory("rea-bundle-bom-mcp-"); const path = join(root, "bundle.json");