diff --git a/docs/ghidra-nativeaot.md b/docs/ghidra-nativeaot.md index 2a48e04038..15393aa9db 100644 --- a/docs/ghidra-nativeaot.md +++ b/docs/ghidra-nativeaot.md @@ -131,6 +131,11 @@ establishing their absence. Performance on the large bflat report has not been measured; this change removes repeated closure scans rather than resolving that separate discovery limitation. +If captured snapshot bytes no longer match their admitted digest, the parser +retains both digests and the Ghidra owner reports `artifact_changed`, with the +captured byte count and admitted identity still available. Reopen acquisition +against a stable artifact; this is distinct from corrupted retained Evidence. + The optional adapter performs its annotations in an ephemeral Program transaction. That workflow may change analysis-memory bytes, never the original executable file. It is unavailable on Windows P0, which has no database diff --git a/src/domain/native/nativeAotPe.test.ts b/src/domain/native/nativeAotPe.test.ts index 48e1aab1e9..282dc1498e 100644 --- a/src/domain/native/nativeAotPe.test.ts +++ b/src/domain/native/nativeAotPe.test.ts @@ -120,11 +120,18 @@ describe("read-only NativeAOT PE parser", () => { ); }); - it("fails closed on snapshot identity mismatch", () => { + it("fails closed on snapshot identity mismatch while preserving observed identity", () => { const bytes = nativeAotPeFixture(); - expect(() => - parseNativeAotPe(bytes, "0".repeat(64), SUMMARY_BUDGET), - ).toThrow(/digest/iu); + try { + parseNativeAotPe(bytes, "0".repeat(64), SUMMARY_BUDGET); + throw new Error("Expected source identity mismatch"); + } catch (cause: unknown) { + expect(cause).toMatchObject({ + name: "NativeAotSnapshotIdentityError", + expectedSha256: "0".repeat(64), + observedSha256: nativeAotPeDigest(bytes), + }); + } }); it("reports a detected but unsupported RTR version as partial without guessed types", () => { diff --git a/src/domain/native/nativeAotPe.ts b/src/domain/native/nativeAotPe.ts index fb98b322fb..6b9984faca 100644 --- a/src/domain/native/nativeAotPe.ts +++ b/src/domain/native/nativeAotPe.ts @@ -73,6 +73,19 @@ class NativeAotParserBudget { } } +/** Source-byte identity mismatch retained for adapter-specific recovery. */ +export class NativeAotSnapshotIdentityError extends Error { + constructor( + readonly expectedSha256: string, + readonly observedSha256: string, + ) { + super( + `NativeAOT snapshot digest does not match the admitted artifact identity: expected ${expectedSha256}, observed ${observedSha256}`, + ); + this.name = "NativeAotSnapshotIdentityError"; + } +} + /** Hydration stops by throwing; other scans record truncation and continue. */ class NativeAotParserBudgetError extends Error { constructor(readonly budget: "work" | "working-memory") { @@ -1272,9 +1285,7 @@ export const parseNativeAotPe = ( ); const sha256 = createHash("sha256").update(bytes).digest("hex"); if (sha256 !== expectedSha256) - throw new Error( - "NativeAOT snapshot digest does not match the admitted artifact identity", - ); + throw new NativeAotSnapshotIdentityError(expectedSha256, sha256); let image: PeImage; try { image = new PeImage(bytes); diff --git a/src/ghidra/GhidraNativeAotSnapshotOwner.ts b/src/ghidra/GhidraNativeAotSnapshotOwner.ts index 7b3169b775..6226351c6e 100644 --- a/src/ghidra/GhidraNativeAotSnapshotOwner.ts +++ b/src/ghidra/GhidraNativeAotSnapshotOwner.ts @@ -4,9 +4,13 @@ import type { AnalysisPartialObservation, } from "../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { + AnalysisArtifactChangedError, + AnalysisCancelledError, +} from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { + NativeAotSnapshotIdentityError, NATIVE_AOT_MAX_REPORT_BYTES, NATIVE_AOT_MAX_SOURCE_BYTES, nativeAotSnapshotOverCapacity, @@ -41,6 +45,7 @@ export class GhidraNativeAotSnapshotOwner { constructor( private readonly input: { readonly targetSha256: string; + readonly targetPath: string; readonly readSnapshot: GhidraClient["readTargetSnapshot"] | undefined; readonly projectFailure: ( operation: AnalysisOperation, @@ -198,6 +203,29 @@ export class GhidraNativeAotSnapshotOwner { imageBase: string, observations: JsonValue, ): AnalysisError { + if (failure.cause instanceof NativeAotSnapshotIdentityError) { + const mismatch = failure.cause; + const primary = new AnalysisArtifactChangedError( + operation, + this.input.targetPath, + mismatch.message, + { + cause: mismatch, + diagnostics: { + expected_sha256: mismatch.expectedSha256, + observed_sha256: mismatch.observedSha256, + }, + partialObservation: failure.partialObservation, + }, + ); + return failure.cleanupFailure?.cleanupIncomplete + ? analysisErrorWithCleanupFailure( + primary, + failure.cleanupFailure, + operation, + ).bindCleanupDependencies([primary, failure.cleanupFailure]) + : primary; + } if (failure.kind !== "cancelled" || this.#conflict === undefined) return this.input.projectFailure( operation, diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 057ccb0748..3cf64d717c 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -2166,6 +2166,44 @@ it.each(["captured", "cancelled-with-facts"] as const)( }, ); +it("preserves the observed NativeAOT snapshot digest with source-change recovery", async () => { + const bytes = nativeAotPeFixture(); + const expected = "0".repeat(64); + const owner = new GhidraNativeAotSnapshotOwner({ + targetSha256: expected, + targetPath: "/fixtures/admitted.exe", + readSnapshot: async () => ok({ kind: "captured", bytes }), + projectFailure: () => { + throw new Error("Identity mismatch must retain source-change recovery"); + }, + }); + try { + const result = await owner.derive( + "0x140000000", + "inspect_native_load_image", + undefined, + {}, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatchObject({ + _tag: "AnalysisArtifactChangedError", + path: "/fixtures/admitted.exe", + diagnostics: { + expected_sha256: expected, + observed_sha256: nativeAotPeDigest(bytes), + }, + partialObservation: { + kind: "native-aot-snapshot", + analysis_artifact_sha256: expected, + capture: { status: "captured", bytes: bytes.length }, + }, + }); + } finally { + await owner.close(); + } +}); + it("preserves NativeAOT source facts and cleanup when a later caller contradicts identity", async () => { const partial = { kind: "file-snapshot-hash" as const, @@ -2181,6 +2219,7 @@ it("preserves NativeAOT source facts and cleanup when a later caller contradicts onTestFinished(() => lease.release()); const owner = new GhidraNativeAotSnapshotOwner({ targetSha256: partial.sha256, + targetPath: partial.path, readSnapshot: async () => err( new GhidraSessionError( diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 31bc26b910..a8a3da77a1 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -532,6 +532,7 @@ export const createGhidraProviderClient = (input: { target.format === "pe" ? new GhidraNativeAotSnapshotOwner({ targetSha256: target.sha256, + targetPath: target.path, readSnapshot: client.readTargetSnapshot?.bind(client), projectFailure: (operation, failure) => projectSessionError(