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", + ]); +});