diff --git a/src/ghidra/GhidraNativeAotSnapshotOwner.ts b/src/ghidra/GhidraNativeAotSnapshotOwner.ts index 478c9fbd6c..7b3169b775 100644 --- a/src/ghidra/GhidraNativeAotSnapshotOwner.ts +++ b/src/ghidra/GhidraNativeAotSnapshotOwner.ts @@ -1,5 +1,8 @@ import type { AnalysisOperation } from "../application/AnalysisProvider.js"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { + AnalysisError, + AnalysisPartialObservation, +} from "../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -58,12 +61,10 @@ export class GhidraNativeAotSnapshotOwner { observations: JsonValue, ): Promise> { if (callerSignal?.aborted) - return err( - new AnalysisCancelledError(operation, { cause: callerSignal.reason }), - ); + return err(this.#cancelled(operation, callerSignal.reason, observations)); if (this.#closed) return err( - new AnalysisCancelledError(operation, { cause: STARTUP_OWNER_CLOSED }), + this.#cancelled(operation, STARTUP_OWNER_CLOSED, observations), ); if (this.#conflict !== undefined) return err(this.#identityError(operation, imageBase, observations)); @@ -94,7 +95,9 @@ export class GhidraNativeAotSnapshotOwner { } this.#base ??= normalized; if (this.cleanupFailure !== undefined && this.#failure !== undefined) - return err(this.input.projectFailure(operation, this.#failure)); + return err( + this.#projectFailure(operation, this.#failure, imageBase, observations), + ); if (this.#cached !== undefined) { const cached = this.#cached; await Promise.resolve(); @@ -107,7 +110,8 @@ export class GhidraNativeAotSnapshotOwner { callerSignal?.aborted ? callerSignal.reason : STARTUP_OWNER_CLOSED, - cached, + observations, + this.#observation(cached), ), ) : ok(cached); @@ -131,9 +135,7 @@ export class GhidraNativeAotSnapshotOwner { ); if (waited.status === "cancelled") return err( - new AnalysisCancelledError(operation, { - cause: callerSignal?.reason, - }), + this.#cancelled(operation, callerSignal?.reason, observations), ); if (callerSignal?.aborted) return err( @@ -141,12 +143,15 @@ export class GhidraNativeAotSnapshotOwner { ? this.#cancelled( operation, callerSignal.reason, - waited.value.value, + observations, + this.#observation(waited.value.value), ) - : new AnalysisCancelledError(operation, { - cause: callerSignal.reason, - partialObservation: waited.value.error.partialObservation, - }), + : this.#cancelled( + operation, + callerSignal.reason, + observations, + waited.value.error.partialObservation, + ), ); return this.derive(imageBase, operation, callerSignal, observations); } @@ -154,7 +159,7 @@ export class GhidraNativeAotSnapshotOwner { const waited = await attempt.wait(callerSignal); if (waited.status === "cancelled") return err( - new AnalysisCancelledError(operation, { cause: callerSignal?.reason }), + this.#cancelled(operation, callerSignal?.reason, observations), ); const settled = waited.value; // A completed physical failure or retained cleanup outranks a later abort. @@ -174,7 +179,8 @@ export class GhidraNativeAotSnapshotOwner { : this.#closed ? STARTUP_OWNER_CLOSED : attempt.signal.reason, - settled.value, + observations, + this.#observation(settled.value), ), ); return settled; @@ -193,7 +199,13 @@ export class GhidraNativeAotSnapshotOwner { observations: JsonValue, ): AnalysisError { if (failure.kind !== "cancelled" || this.#conflict === undefined) - return this.input.projectFailure(operation, failure); + return this.input.projectFailure( + operation, + failure.withDiagnostics({ + ...failure.diagnostics, + measured_observation: observations, + }), + ); return this.#identityError(operation, imageBase, observations, failure); } @@ -319,11 +331,13 @@ export class GhidraNativeAotSnapshotOwner { #cancelled( operation: AnalysisOperation, reason: unknown, - result: NativeAotPeResult, + observations: JsonValue, + partialObservation?: AnalysisPartialObservation, ): AnalysisError { return new AnalysisCancelledError(operation, { cause: reason, - partialObservation: this.#observation(result), + diagnostics: { measured_observation: observations }, + partialObservation, }); } @@ -331,7 +345,9 @@ export class GhidraNativeAotSnapshotOwner { operation: AnalysisOperation, imageBase: string, observations: JsonValue, - source?: Pick, + source: + | Pick + | undefined = this.#failure, ): AnalysisError { const primary = new ProviderAdapterError("ghidra", operation, { partialObservation: source?.partialObservation ?? this.#observation(), diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 79076c773d..5904c89a88 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -1,3 +1,4 @@ +import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { resolvedAnalysisError } from "../domain/analysisErrorBase.js"; import { createDeferred } from "../../tests/fixtures/binarySession.js"; @@ -2049,6 +2050,49 @@ it.each(["captured", "cancelled-with-facts"] as const)( }, ); +it("preserves NativeAOT source facts and cleanup when a later caller contradicts identity", async () => { + const partial = { + kind: "file-snapshot-hash" as const, + path: "/controlled/snapshot", + sha256: "a".repeat(64), + bytes: 17, + }; + const cleanup = new ProviderCleanupError("ghidra", [partial.path], { + reason: "controlled close failure", + }); + const lease = new AnalysisCleanupLease(async () => {}, [partial.path]); + lease.bindCleanupError(cleanup); + onTestFinished(() => lease.release()); + const owner = new GhidraNativeAotSnapshotOwner({ + targetSha256: partial.sha256, + readSnapshot: async () => + err( + new GhidraSessionError( + "protocol", + "Controlled snapshot failure", + {}, + { partialObservation: partial }, + ).withCleanupFailure(cleanup), + ), + projectFailure: () => cleanup, + }); + await owner.derive("0x140000000", "inspect_native_load_image", undefined, { + image_base: "0x140000000", + }); + const conflict = await owner.derive( + "0x150000000", + "inspect_native_load_image", + undefined, + { image_base: "0x150000000" }, + ); + if (conflict.ok) throw new Error("Expected conflicting snapshot identity"); + expect(conflict.error.partialObservation).toEqual(partial); + expect(conflict.error.cleanupIncomplete).toBe(true); + await lease.release(); + expect(conflict.error.cleanupIncomplete).toBe(false); + await owner.close(); +}); + const nativeAotWaiterClient = async () => { const bytes = nativeAotPeFixture(); const observations = fixtureDosLoadImage().observation; @@ -2250,8 +2294,14 @@ it.each(["caller", "owner"] as const)( const closed = await closing; expect(closed.ok).toBe(false); } + expect(result.error.cleanupPrimary?.diagnostics).toMatchObject({ + measured_observation: { image_base: "0x140000000" }, + }); await lease.release(); expect(result.error.cleanupIncomplete).toBe(false); + expect(resolvedAnalysisError(result.error).diagnostics).toMatchObject({ + measured_observation: { image_base: "0x140000000" }, + }); expect((await fixture.client.close()).ok).toBe(true); }, );