fix(ghidra): retain measured facts through NativeAOT failures

Attach each caller’s measured observations before source failure and cleanup projection. Preserve those facts through admission, peer and draining cancellation, cached results, cleanup fences and later lease resolution.

Validation: selected NativeAOT provider controls and typecheck/lint passed in the load-image audit tree. Related consumer validation follows in the next commit.

Refs #3977, #4753
This commit is contained in:
morluto committed 2026-10-17 18:52:42 +08:00
1 parent ae029fe2f8
commit a43ce8a98f
2 files changed
+87 -21

No files matched your search

+37 -21
View File
@@ -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<Result<NativeAotPeResult, AnalysisError>> {
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<GhidraSessionError, "partialObservation" | "cleanupFailure">,
source:
| Pick<GhidraSessionError, "partialObservation" | "cleanupFailure">
| undefined = this.#failure,
): AnalysisError {
const primary = new ProviderAdapterError("ghidra", operation, {
partialObservation: source?.partialObservation ?? this.#observation(),
@@ -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);
},
);