From 54756ff24e1f4c2e47554f61ea5efe57bab27b64 Mon Sep 17 00:00:00 2001 From: Entrpi <133157614+Entrpi@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:35:53 +1100 Subject: [PATCH] test: guard target format vocabularies and DOL snapshot saves (#7481) * test(mcp): save and reopen a GameCube DOL snapshot #7290 reported that DOL Evidence could not be bundled or saved; 50822e1ef fixed the bundle manifest and added bundle export/import coverage, but no test saved a DOL session through close_binary. Save a DOL snapshot with recorded Evidence, check its target and artifact manifest, then reopen it and replay the read without running the provider again. * test(binary): require every target format in each target vocabulary A new target format must be added by hand to the Evidence subject, bundle manifest, snapshot target and live summary vocabularies; missing one caused #7290. Give every BinaryTarget format a fixture through a mapped type, so a new format fails compilation until it has one, and push each fixture through the producers that persist or project targets. Caller-selected format hints must name executable target formats. This is a guard until target formats have a single owner. * test(mcp): assert the replayed DOL result and the owned replay limitation The DOL snapshot replay test checked the subject and the provider-call count but not the answer, so a replay returning null or another value would pass. Compare the replayed normalized result with the original. Name the replay limitation as SNAPSHOT_REPLAY_LIMITATION in the snapshot cache that adds it, and assert that value instead of repeating the sentence. * test: list Intel HEX and S-record targets in the vocabulary guard Upstream added the intel-hex and srec formats (#7136). The guard requires a fixture for every target format, so after merging main it no longer typechecked; both formats now have archive fixtures and pass the same persistence and projection checks. * test: list UnityFS among the target format fixtures Upstream #7699 added the unityfs target format, so the vocabulary test's per-format fixtures must name it; without one the test no longer typechecks against main. * test: list uImage among the target format fixtures Upstream #7234 added the uimage target format, so the vocabulary test's per-format fixtures must name it; without one the test no longer typechecks against main. * test: list the ESP image among the target format fixtures Upstream #7498 added the esp-image target format, so the vocabulary test's per-format fixtures must name it; without one the test no longer typechecks against main. --- .../binary/AnalysisSnapshotCache.ts | 6 +- .../binary/TargetFormatVocabularies.test.ts | 177 ++++++++++++++++++ .../mcp/gamecubeDolSessionMcp.test.ts | 68 +++++++ 3 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/application/binary/TargetFormatVocabularies.test.ts diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index e5917209dd..abd8ccd7f2 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -140,6 +140,10 @@ export const requiresNativeSourceAdmissionBeforeReplay = ( providerId: string | undefined, ): boolean => process.platform === "win32" && providerId === "ghidra"; +/** Limitation that marks every observation replayed from a snapshot. */ +export const SNAPSHOT_REPLAY_LIMITATION = + "Loaded from a local REA analysis snapshot; this call did not re-run the provider."; + /** Bounded in-memory cache for one immutable binary identity. */ export class AnalysisSnapshotCache { readonly #entries = new Map(); @@ -496,7 +500,7 @@ export class AnalysisSnapshotCache { analysisProfile: structuredClone(profile), limitations: [ ...cached.execution.limitations, - "Loaded from a local REA analysis snapshot; this call did not re-run the provider.", + SNAPSHOT_REPLAY_LIMITATION, ], ...(cached.execution.relationship_coverage === null && RELATIONSHIP_OPERATIONS.has(operation) diff --git a/src/application/binary/TargetFormatVocabularies.test.ts b/src/application/binary/TargetFormatVocabularies.test.ts new file mode 100644 index 0000000000..2e9185bdd6 --- /dev/null +++ b/src/application/binary/TargetFormatVocabularies.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; + +import { + analysisSnapshotSchema, + snapshotTarget, +} from "../../domain/analysisSnapshot.js"; +import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; +import { executableFormatHintSchema } from "../../domain/dosCom.js"; +import { createEvidence, parseEvidence } from "../../domain/evidence.js"; +import { + createImmutableEvidenceBundle, + parseEvidenceBundle, +} from "../../domain/evidenceBundle.js"; +import { binaryTargetSummary } from "./BinaryTargetSummary.js"; + +/** + * One admitted target per format. A format added to `BinaryTarget` fails + * compilation here until it has a fixture, and the cases below then require + * every vocabulary that persists or projects targets to accept it. This guards + * the failure class of #7290 until target formats have a single owner. + */ +type TargetFixtures = { + readonly [Format in BinaryTarget["format"]]: BinaryTarget & { + readonly format: Format; + }; +}; + +const identity = { path: "/fixture/target", sha256: "a".repeat(64) } as const; +const archive = { ...identity, kind: "archive" } as const; +const artifact = { ...identity, kind: "artifact" } as const; + +const TARGETS: TargetFixtures = { + "mach-o": { + ...identity, + kind: "executable", + format: "mach-o", + architecture: "arm64", + availableArchitectures: ["arm64"], + }, + "mach-o-universal": { + ...identity, + kind: "executable", + format: "mach-o-universal", + architecture: "arm64", + availableArchitectures: ["x86_64", "arm64"], + universalSlice: { offset: 0x4000, size: 0x4000 }, + }, + elf: { + ...identity, + kind: "executable", + format: "elf", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + }, + pe: { + ...identity, + kind: "executable", + format: "pe", + architecture: "x86_64", + availableArchitectures: ["x86_64"], + executableRole: "application", + managed: false, + }, + "dos-mz": { + ...identity, + kind: "executable", + format: "dos-mz", + architecture: "x86", + availableArchitectures: ["x86"], + }, + "dos-com": { + ...identity, + kind: "executable", + format: "dos-com", + architecture: "x86", + availableArchitectures: ["x86"], + }, + // A raw-only family: detected formats must not admit it. + "raw-binary": { + ...identity, + kind: "executable", + format: "raw-binary", + architecture: "m68k", + availableArchitectures: ["m68k"], + entryPoint: "0x1000", + entryPointSource: "0x1000", + }, + "gamecube-dol": { + ...identity, + kind: "executable", + format: "gamecube-dol", + architecture: "powerpc", + availableArchitectures: ["powerpc"], + byteOrder: "big", + bits: 32, + entryPoint: "0x80003100", + entryPointSource: "dol-header", + dol: { + sections: [], + bss: { address: 0, size: 0 }, + bssOverlaps: [], + bssGaps: [], + entryPoint: 0x8000_3100, + }, + }, + "analysis-database": { + ...identity, + kind: "database", + format: "analysis-database", + }, + zip: { ...archive, format: "zip" }, + ipa: { ...archive, format: "ipa" }, + apk: { ...archive, format: "apk" }, + msix: { ...archive, format: "msix" }, + appx: { ...archive, format: "appx" }, + hap: { ...archive, format: "hap" }, + hsp: { ...archive, format: "hsp" }, + "app-pack": { ...archive, format: "app-pack" }, + asar: { ...archive, format: "asar" }, + "godot-pck": { ...archive, format: "godot-pck" }, + unityfs: { ...archive, format: "unityfs" }, + uimage: { ...archive, format: "uimage" }, + dmg: { ...archive, format: "dmg" }, + pkg: { ...archive, format: "pkg" }, + "intel-hex": { ...archive, format: "intel-hex" }, + srec: { ...archive, format: "srec" }, + "esp-image": { ...archive, format: "esp-image" }, + fit: { ...archive, format: "fit" }, + plist: { ...artifact, format: "plist" }, + javascript: { ...artifact, format: "javascript" }, + "source-map": { ...artifact, format: "source-map" }, +}; + +const provider = { id: "fixture", name: "Fixture", version: "1" } as const; +const roundTrip = (value: unknown): unknown => + JSON.parse(JSON.stringify(value)); + +describe("target format vocabularies", () => { + it.each(Object.values(TARGETS))( + "persists and projects $format targets", + (target) => { + const evidence = createEvidence(target, provider, { + operation: "fixture", + parameters: {}, + result: { observed: true }, + }); + expect(parseEvidence(roundTrip(evidence)).subject).toMatchObject({ + format: target.format, + architecture: target.architecture ?? null, + }); + const bundle = createImmutableEvidenceBundle([evidence]); + expect(parseEvidenceBundle(roundTrip(bundle)).artifacts).toEqual([ + { + digest: { sha256: target.sha256 }, + format: target.format, + architecture: target.architecture ?? null, + }, + ]); + expect( + analysisSnapshotSchema.shape.target.parse( + roundTrip(snapshotTarget(target)), + ), + ).toMatchObject({ format: target.format, kind: target.kind }); + expect(binaryTargetSummary(target)).toMatchObject({ + format: target.format, + kind: target.kind, + }); + }, + ); + + it.each(executableFormatHintSchema.options)( + "admits the caller-selected %s hint only as an executable target format", + (hint) => { + expect(TARGETS[hint]).toMatchObject({ format: hint, kind: "executable" }); + }, + ); +}); diff --git a/tests/boundary/mcp/gamecubeDolSessionMcp.test.ts b/tests/boundary/mcp/gamecubeDolSessionMcp.test.ts index 2be4f9c5f2..383b8795bc 100644 --- a/tests/boundary/mcp/gamecubeDolSessionMcp.test.ts +++ b/tests/boundary/mcp/gamecubeDolSessionMcp.test.ts @@ -8,7 +8,10 @@ import { expect, it } from "vitest"; import { z } from "zod"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { SNAPSHOT_REPLAY_LIMITATION } from "../../../src/application/binary/AnalysisSnapshotCache.js"; +import { readAnalysisSnapshot } from "../../../src/application/binary/AnalysisSnapshotFiles.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; import { buildGamecubeDol } from "../../../src/domain/gamecubeDol.fixture.js"; import { silentLogger } from "../../../src/logger.js"; import { @@ -74,3 +77,68 @@ it("opens a GameCube DOL and validates binary_session status against its output entry_point: "0x80003100", }); }); + +// #7290: the bundle manifest lacked the DOL subject declaration, so DOL +// Evidence could not be bundled or saved with close_binary. +it("saves a GameCube DOL snapshot and replays it after reopening", async () => { + const root = await createTestTempDirectory("rea-dol-snapshot-mcp-"); + const path = join(root, "game.dol"); + const snapshotPath = join(root, "analysis.json"); + await writeFile(path, buildGamecubeDol()); + const calls: string[] = []; + const { client } = await createApplicationMcpHarness({ + session: createTestBinarySession(createCacheProvider(calls)), + serverOptions: { logger: silentLogger }, + clientIdentity: { name: "dol-snapshot-mcp", version: "1" }, + }); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path, format: "gamecube-dol" }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + // A document selector makes this read replayable from a snapshot. + const query = { + name: "address_name", + arguments: { address: "0x80003100", document: "game.dol" }, + }; + const named = await client.callTool(query); + expect(named.isError, JSON.stringify(named)).not.toBe(true); + const original = parseEvidence(named.structuredContent); + + const saved = await client.callTool({ + name: "close_binary", + arguments: { snapshot_path: snapshotPath }, + }); + expect(saved.isError, JSON.stringify(saved)).not.toBe(true); + const snapshot = await readAnalysisSnapshot(snapshotPath); + if (!snapshot.ok) throw snapshot.error; + expect(snapshot.value.target).toMatchObject({ + format: "gamecube-dol", + kind: "executable", + architecture: "powerpc", + entry_point: "0x80003100", + }); + expect(snapshot.value.entries).toHaveLength(1); + expect(snapshot.value.evidence_bundle.artifacts).toEqual([ + expect.objectContaining({ + format: "gamecube-dol", + architecture: "powerpc", + }), + ]); + + const reopened = await client.callTool({ + name: "open_binary", + arguments: { path, format: "gamecube-dol", snapshot_path: snapshotPath }, + }); + expect(reopened.isError, JSON.stringify(reopened)).not.toBe(true); + const replayed = parseEvidence( + (await client.callTool(query)).structuredContent, + ); + expect(original.normalized_result).toBe("address_name"); + expect(replayed.subject).toEqual(original.subject); + expect(replayed.normalized_result).toEqual(original.normalized_result); + expect(replayed.limitations).toContain(SNAPSHOT_REPLAY_LIMITATION); + expect(calls.filter((operation) => operation === "address_name")).toEqual([ + "address_name", + ]); +});