fix(nativeaot): retain snapshot digest mismatch and recovery

This commit is contained in:
morluto committed 2026-10-24 02:45:14 +08:00
1 parent fc05f68406
commit d8e90fe621
6 files changed
+99 -8

No files matched your search

+5
View File
@@ -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
+11 -4
View File
@@ -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", () => {
+14 -3
View File
@@ -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);
+29 -1
View File
@@ -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,
@@ -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(
+1
View File
@@ -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(