From ebea0f009ffc6353f0ae61254b1d2b5dcc4f08d2 Mon Sep 17 00:00:00 2001 From: hhang <17309584214@163.com> Date: Wed, 4 Nov 2026 19:50:39 +0800 Subject: [PATCH] fix(snapshot): retain one-shot Ghidra pseudocode results (#8089) * fix(snapshot): retain one-shot Ghidra pseudocode results * test(snapshot): cover evidence-only pseudocode migration --------- Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com> --- docs/cli.md | 5 + src/application/AnalysisProvider.ts | 2 + src/application/DirectAnalysis.ts | 9 +- .../binary/AnalysisSnapshotCache.test.ts | 31 ++++ .../binary/AnalysisSnapshotCache.ts | 21 ++- src/application/binary/BinarySession.ts | 8 +- .../binary/BinarySessionExecution.ts | 9 +- .../directAnalysisSnapshotReplay.test.ts | 164 ++++++++++++++++++ 8 files changed, 243 insertions(+), 6 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index bcb344daaf..28af2b71e4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -637,6 +637,11 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat ``` An exact CLI cache hit is normally read before starting a provider process. +`decompile --provider ghidra --snapshot ...` also retains its single selected +Program's pseudocode result, even without an explicit document selector. Old +snapshots that contain only the Evidence need one new query to add that replay +entry. This does not retain every body from `decompile-all` or make a persistent +session's implicit document replayable. On Windows, Ghidra first admits the selected source through its native snapshot owner, so replay can start that provider. In MCP, `open_binary` accepts `snapshot_path` to import a snapshot atomically diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 6940e3c421..63a498d810 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -30,6 +30,8 @@ import { export interface ExecutionOptions { /** Ephemeral state shared only by one composed workflow. */ readonly workScope?: AnalysisWorkScope; + /** A single CLI answer bound to its isolated target, not a persistent default document. */ + readonly snapshotContext?: "one-shot-target"; readonly signal?: AbortSignal; readonly progress?: ProgressReporter; /** diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 70935557c5..bb4f104ba2 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -484,6 +484,7 @@ const runAnalysis = async ( evidenceParameters, signal, evidenceProfile, + saveSnapshot: snapshotPath !== undefined, }); if (!executed.ok) return executed; const { cachePolicy } = executed.value; @@ -594,6 +595,7 @@ const executeAnalysisTool = async (input: { readonly evidenceParameters: Readonly>; readonly signal: AbortSignal; readonly evidenceProfile: AnalysisProfileCommitment | undefined; + readonly saveSnapshot: boolean; }): Promise< Result< { @@ -658,7 +660,12 @@ const executeAnalysisTool = async (input: { : { cachePolicy: result.value.cachePolicy }), }); } - let result = await session.execute(tool, input.arguments, { signal }); + let result = await session.execute(tool, input.arguments, { + signal, + ...(input.saveSnapshot && tool === "procedure_pseudo_code" + ? { snapshotContext: "one-shot-target" as const } + : {}), + }); if ( tool === "xrefs" && typeof input.arguments.address === "string" && diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index d3396d4a6a..bf048e6f3e 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -777,7 +777,9 @@ describe("analysis snapshot cache policy", () => { ); }, ); +}); +describe("document-scoped snapshot admission", () => { it("retains caller observations without a document while refusing to replay them", () => { const descriptor: CapabilityDescriptor = { operation: "procedure_callers", @@ -829,6 +831,35 @@ describe("analysis snapshot cache policy", () => { document: "fixture", }), ).toBe(true); + const ghidra = { + ...pseudocode, + provider: { ...pseudocode.provider, id: "ghidra" }, + }; + expect( + isSnapshotCacheable("procedure_pseudo_code", ghidra, parameters), + ).toBe(false); + expect( + isSnapshotCacheable( + "procedure_pseudo_code", + ghidra, + parameters, + "one-shot-target", + ), + ).toBe(true); + for (const refused of [ + pseudocode, + { ...ghidra, cachePolicy: "live" as const }, + { ...ghidra, effects: { ...ghidra.effects, mutatesArtifact: true } }, + { ...ghidra, effects: { ...ghidra.effects, changesPermissions: true } }, + ]) + expect( + isSnapshotCacheable( + "procedure_pseudo_code", + refused, + parameters, + "one-shot-target", + ), + ).toBe(false); }); }); diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 1289c2cb43..3232b79c60 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -36,6 +36,7 @@ import type { AnalysisExecution, AnalysisOperation, CapabilityDescriptor, + ExecutionOptions, } from "../AnalysisProvider.js"; import { OFFICIAL_TOOL_CONTRACTS } from "../../contracts/officialToolContracts.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; @@ -94,6 +95,16 @@ const replayDocumentSelected = ( !DOCUMENT_SCOPED_OPERATIONS.has(operation) || typeof parameters.document === "string"; +/** Ghidra's private Program belongs to this one-shot target; GUI defaults do not. */ +const isOneShotPseudocode = ( + operation: AnalysisOperation, + descriptor: CapabilityDescriptor | undefined, + context: ExecutionOptions["snapshotContext"], +): boolean => + context === "one-shot-target" && + operation === "procedure_pseudo_code" && + descriptor?.provider.id === "ghidra"; + /** * Caller and callee walks omit `document`. Their observations are still * Evidence. Other document-scoped reads, including `procedure_pseudo_code` @@ -112,12 +123,14 @@ export const isSourceEvidenceRetainable = ( operation: AnalysisOperation, descriptor: CapabilityDescriptor | undefined, parameters: Readonly>, + context?: ExecutionOptions["snapshotContext"], ): descriptor is CapabilityDescriptor => !STATEFUL_OPERATIONS.has(operation) && !RESOURCE_SCOPED_OPERATIONS.has(operation) && (!DOCUMENT_SCOPED_OPERATIONS.has(operation) || typeof parameters.document === "string" || - DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS.has(operation)) && + DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS.has(operation) || + isOneShotPseudocode(operation, descriptor, context)) && (!CURSOR_DEFAULT_OPERATIONS.has(operation) || typeof parameters.address === "string") && descriptor?.effects.mutatesArtifact === false && @@ -130,9 +143,11 @@ export const isSnapshotCacheable = ( operation: AnalysisOperation, descriptor: CapabilityDescriptor | undefined, parameters: Readonly>, + context?: ExecutionOptions["snapshotContext"], ): descriptor is CapabilityDescriptor => - isSourceEvidenceRetainable(operation, descriptor, parameters) && - replayDocumentSelected(operation, parameters) && + isSourceEvidenceRetainable(operation, descriptor, parameters, context) && + (replayDocumentSelected(operation, parameters) || + isOneShotPseudocode(operation, descriptor, context)) && descriptor.cachePolicy !== "live"; /** Windows Ghidra must admit the selected source through its native snapshot owner. */ diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 84369c5253..87129021d3 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -1418,6 +1418,7 @@ export class BinarySession active: this.#active, operation: name, parameters: arguments_, + snapshotContext: options?.snapshotContext, nativeSourceAdmitted: this.#active?.nativeSourceAdmitted === true, unboundOperationError: (operation, route) => this.#providerRouter.unboundOperationError(operation, route), @@ -1479,7 +1480,12 @@ export class BinarySession } if ( execution.ok && - isSourceEvidenceRetainable(name, capability, arguments_) && + isSourceEvidenceRetainable( + name, + capability, + arguments_, + options?.snapshotContext, + ) && profile !== undefined ) { const evidence = createAnalysisExecutionEvidence( diff --git a/src/application/binary/BinarySessionExecution.ts b/src/application/binary/BinarySessionExecution.ts index 20d4dd063d..fa0703f93b 100644 --- a/src/application/binary/BinarySessionExecution.ts +++ b/src/application/binary/BinarySessionExecution.ts @@ -15,6 +15,7 @@ import type { AnalysisExecution, AnalysisOperation, CapabilityDescriptor, + ExecutionOptions, } from "../AnalysisProvider.js"; import { isSnapshotCacheable, @@ -41,6 +42,7 @@ interface PrepareSessionExecutionInput { readonly active: ActiveExecutionBinding | undefined; readonly operation: AnalysisOperation; readonly parameters: Readonly>; + readonly snapshotContext?: ExecutionOptions["snapshotContext"]; readonly nativeSourceAdmitted: boolean; readonly unboundOperationError: ( operation: AnalysisOperation, @@ -94,7 +96,12 @@ export const prepareSessionExecution = ( : undefined; const cacheable = profile !== undefined && - isSnapshotCacheable(operation, capability, parameters); + isSnapshotCacheable( + operation, + capability, + parameters, + input.snapshotContext, + ); const cached = cacheable && (!requiresNativeSourceAdmissionBeforeReplay(capability.provider.id) || diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index d26a2e7381..4180ca2d03 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -289,6 +289,170 @@ const createSnapshotReplayFixture = async ( }; }; +describe("one-shot Ghidra pseudocode snapshots", () => { + it("keeps persistent documentless reads out of the provider cache", async () => { + const fixture = await createSnapshotReplayFixture("rea-session-code-"); + const session = createTestBinarySession( + fixture.createProvider({ + profile: createAnalysisProfile( + { ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" }, + { fixture: true }, + ), + supportedOperations: ["procedure_pseudo_code"], + }), + ); + try { + expect((await session.open(fixture.path)).ok).toBe(true); + for (let index = 0; index < 2; index++) + expect( + ( + await session.execute("procedure_pseudo_code", { + procedure: "fixture", + }) + ).ok, + ).toBe(true); + const snapshot = session.exportAnalysisSnapshot(); + if (!snapshot.ok) throw snapshot.error; + expect(snapshot.value.entries).toEqual([]); + expect(snapshot.value.evidence_bundle.records).toEqual([]); + expect( + fixture.calls.filter((name) => name === "procedure_pseudo_code"), + ).toHaveLength(2); + } finally { + expect((await session.close()).ok).toBe(true); + } + }); + + it.skipIf(process.platform === "win32")( + "replays the exact selected-target answer without opening another provider", + async () => { + const fixture = await createSnapshotReplayFixture("rea-one-shot-code-"); + const profile = createAnalysisProfile( + { ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" }, + { fixture: true }, + ); + const dependencies = fixture.dependencies( + fixture.createProvider({ + profile, + supportedOperations: ["procedure_pseudo_code"], + }), + ); + const options = { snapshotPath: fixture.snapshotPath }; + const parameters = { procedure: "fixture" }; + const first = await runDirectAnalysis( + dependencies, + fixture.path, + "procedure_pseudo_code", + parameters, + options, + ); + expect(first.ok).toBe(true); + const second = await runDirectAnalysis( + dependencies, + fixture.path, + "procedure_pseudo_code", + parameters, + options, + ); + expect(second).toEqual(first); + expect(fixture.starts).toHaveLength(1); + const saved = await readAnalysisSnapshot(fixture.snapshotPath); + if (!saved.ok) throw saved.error; + expect(saved.value.entries).toMatchObject([ + { operation: "procedure_pseudo_code", parameters }, + ]); + }, + ); + + it("does not make another provider's implicit document replayable", async () => { + const fixture = await createSnapshotReplayFixture("rea-implicit-document-"); + const dependencies = fixture.dependencies( + fixture.createProvider({ + supportedOperations: ["procedure_pseudo_code"], + }), + ); + for (let index = 0; index < 2; index++) { + const result = await runDirectAnalysis( + dependencies, + fixture.path, + "procedure_pseudo_code", + { procedure: "fixture" }, + { snapshotPath: fixture.snapshotPath }, + ); + expect(result.ok).toBe(true); + } + expect(fixture.starts).toHaveLength(2); + const saved = await readAnalysisSnapshot(fixture.snapshotPath); + if (!saved.ok) throw saved.error; + expect(saved.value.entries).toEqual([]); + }); +}); + +describe("legacy one-shot Ghidra pseudocode snapshots", () => { + it.skipIf(process.platform === "win32")( + "restores a missing replay entry without replacing the original Evidence bundle", + async () => { + const fixture = await createSnapshotReplayFixture("rea-legacy-code-"); + const dependencies = fixture.dependencies( + fixture.createProvider({ + profile: createAnalysisProfile( + { ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" }, + { fixture: true }, + ), + supportedOperations: ["procedure_pseudo_code"], + }), + ); + const parameters = { procedure: "fixture" }; + const query = () => + runDirectAnalysis( + dependencies, + fixture.path, + "procedure_pseudo_code", + parameters, + { snapshotPath: fixture.snapshotPath }, + ); + const first = await query(); + if (!first.ok) throw new Error(JSON.stringify(first.error)); + const saved = await readAnalysisSnapshot(fixture.snapshotPath); + if (!saved.ok) throw saved.error; + const written = await withArtifactResourceScope((resources) => + writeAnalysisSnapshot( + { ...saved.value, entries: [] }, + fixture.snapshotPath, + true, + resources, + ), + ); + if (!written.ok) throw written.error; + const legacy = await readAnalysisSnapshot(fixture.snapshotPath); + if (!legacy.ok) throw legacy.error; + expect(legacy.value.entries).toEqual([]); + expect(legacy.value.evidence_bundle).toEqual(saved.value.evidence_bundle); + + const upgraded = await query(); + expect(upgraded).toEqual(first); + expect(fixture.starts).toHaveLength(2); + const restored = await readAnalysisSnapshot(fixture.snapshotPath); + if (!restored.ok) throw restored.error; + expect(restored.value.entries).toMatchObject([ + { operation: "procedure_pseudo_code", parameters }, + ]); + expect(restored.value.evidence_bundle).toEqual( + saved.value.evidence_bundle, + ); + + const replay = await query(); + expect(replay).toEqual(first); + if (!replay.ok) throw new Error(JSON.stringify(replay.error)); + expect(replay.value.evidence_id).toBe(first.value.evidence_id); + expect(fixture.starts).toHaveLength(2); + expect( + fixture.calls.filter((name) => name === "procedure_pseudo_code"), + ).toHaveLength(2); + }, + ); +}); + const withAlternateWorkflowProfile = ( snapshot: AnalysisSnapshot, path: string,