From 3fbdf0057b1cfbfa99d11ec56238d9d574cb727d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:51:19 +0800 Subject: [PATCH] fix(ghidra): commit analysis profiles to the bridge script sources (#10562) * fix(ghidra): commit analysis profiles to the bridge script sources Snapshot reuse for Ghidra depends on the analysis profile digest, but the profile committed only to hand-bumped semantic markers. A bridge change that forgot to bump its marker left earlier snapshots admissible (#3664). Add ghidraBridgeSourcesDigest, a SHA-256 over every packaged bridge source by name, and commit it as bridge_sources_sha256 in every Ghidra profile, for new imports and existing projects alike. A changed script now changes the profile digest without anyone remembering a marker. Existing snapshots are not reused once, after upgrading. GhidraRawProfileDigest.test.ts pinned literal digests that every bridge or marker change had to re-record; it now checks the committed parameters and that the digest is the profile owner's digest of them. A new test checks that the source list covers every packaged .java file. * fix(ghidra): frame bridge digest metadata as a tuple --- src/ghidra/GhidraAnalysisProfile.ts | 14 +++++++--- src/ghidra/GhidraBridgeSources.test.ts | 26 ++++++++++++++++++ src/ghidra/GhidraBridgeSources.ts | 23 ++++++++++++++++ src/ghidra/GhidraRawProfileDigest.test.ts | 33 +++++++++++++---------- 4 files changed, 78 insertions(+), 18 deletions(-) create mode 100644 src/ghidra/GhidraBridgeSources.test.ts diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index d49d0a8d5e..71f1c94a18 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -17,6 +17,7 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../domain/result.js"; import { ghidraDisabledAnalyzerPreset } from "./GhidraDisabledAnalyzers.js"; +import { ghidraBridgeSourcesDigest } from "./GhidraBridgeSources.js"; import type { GhidraInstallationInspection } from "./GhidraInstallation.js"; import { isLegacyMipsAdmission, @@ -54,7 +55,7 @@ import { } from "../domain/native/nativeAotPe.js"; /** Shared analysis commitments for raw and header-selected imports. */ -const sharedAnalysisSemantics = { +const sharedAnalysisSemanticMarkers = { annotation_policy: "atomic-function-entry-metadata-v1", byte_search_boundary: "contiguous-eligible-initialized-blocks-v1", load_image_observations: "source-mappings-entry-context-relocation-symbol-v3", @@ -77,6 +78,11 @@ const sharedAnalysisSemantics = { byte_search_coverage: "separate-loaded-overlays-v1", function_boundary_observations: "ghidra-function-boundary-limitations-v5", } as const; +/** Marker revisions plus the bridge sources that produce every Ghidra result. */ +const sharedAnalysisSemantics = () => ({ + ...sharedAnalysisSemanticMarkers, + bridge_sources_sha256: ghidraBridgeSourcesDigest(), +}); const loaderRelocationSemantics = "volatile-unapplied-relocation-blocks-v1"; // Read-only sessions record relocation limitations without marking blocks volatile. const readOnlyLoaderRelocationSemantics = "read-only-relocation-limitations-v1"; @@ -124,7 +130,7 @@ const binaryLoaderPolicy = ( architecture: target.architecture, available_architectures: [...target.availableArchitectures].sort(), import_mode: "ephemeral-source-immutable", - ...sharedAnalysisSemantics, + ...sharedAnalysisSemantics(), no_return_repair: installation.platform === "win32" ? "disabled-windows-p0" @@ -329,7 +335,7 @@ export const resolveGhidraAnalysisProfile = async ( windows_arm_pe_machine: target.peMachine ?? null, } : {}), - ...sharedAnalysisSemantics, + ...sharedAnalysisSemantics(), // Windows P0 has no database-mutation authority: limitations only. loader_relocation_semantics: installation.platform === "win32" @@ -499,7 +505,7 @@ export const existingGhidraAnalysisProfile = ( architecture: target.architecture ?? null, ...(mipsElf === undefined ? {} : { mips_elf: mipsElf }), import_mode: "existing-project-private-copy-v1", - ...sharedAnalysisSemantics, + ...sharedAnalysisSemantics(), selected_project_relationship_coverage: "prior-analysis-unknown-v1", annotation_policy: "read-only-existing-project-v1", loader_relocation_semantics: readOnlyLoaderRelocationSemantics, diff --git a/src/ghidra/GhidraBridgeSources.test.ts b/src/ghidra/GhidraBridgeSources.test.ts new file mode 100644 index 0000000000..8f088b3edb --- /dev/null +++ b/src/ghidra/GhidraBridgeSources.test.ts @@ -0,0 +1,26 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import { expect, it } from "vitest"; + +import { + GHIDRA_BRIDGE_SOURCES, + ghidraBridgeSourcesDigest, +} from "./GhidraBridgeSources.js"; + +const directory = new URL("../../bridge/ghidra/", import.meta.url); + +it("lists every packaged Ghidra bridge source", async () => { + const java = (await readdir(directory)).filter((name) => + name.endsWith(".java"), + ); + expect(Object.values(GHIDRA_BRIDGE_SOURCES).sort()).toEqual(java.sort()); +}); + +it("commits to the name and bytes of each bridge source", async () => { + const hash = createHash("sha256"); + for (const name of Object.values(GHIDRA_BRIDGE_SOURCES).sort()) { + const bytes = await readFile(new URL(name, directory)); + hash.update(JSON.stringify([name, bytes.byteLength])).update(bytes); + } + expect(ghidraBridgeSourcesDigest()).toBe(hash.digest("hex")); +}); diff --git a/src/ghidra/GhidraBridgeSources.ts b/src/ghidra/GhidraBridgeSources.ts index 509eaa9811..cea0d83885 100644 --- a/src/ghidra/GhidraBridgeSources.ts +++ b/src/ghidra/GhidraBridgeSources.ts @@ -1,3 +1,6 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + /** Required packaged sources, selected by their runtime role rather than order. */ export const GHIDRA_BRIDGE_SOURCES = Object.freeze({ bridge: "ReaGhidraBridge.java", @@ -12,3 +15,23 @@ export const GHIDRA_BRIDGE_SOURCES = Object.freeze({ mipsSoftFloat: "ReaGhidraMipsSoftFloat.java", riscvCallingConvention: "ReaGhidraRiscvCallingConvention.java", } as const); + +let bridgeSourcesDigest: string | undefined; + +/** + * SHA-256 over the packaged bridge sources, keyed by file name. Ghidra results + * depend on these scripts, so the analysis profile commits to them: a changed + * script yields a new profile digest and earlier snapshots are not reused, + * without a hand-bumped semantics marker. + */ +export const ghidraBridgeSourcesDigest = (): string => { + if (bridgeSourcesDigest !== undefined) return bridgeSourcesDigest; + const directory = new URL("../../bridge/ghidra/", import.meta.url); + const hash = createHash("sha256"); + for (const name of Object.values(GHIDRA_BRIDGE_SOURCES).sort()) { + const bytes = readFileSync(new URL(name, directory)); + hash.update(JSON.stringify([name, bytes.byteLength])).update(bytes); + } + bridgeSourcesDigest = hash.digest("hex"); + return bridgeSourcesDigest; +}; diff --git a/src/ghidra/GhidraRawProfileDigest.test.ts b/src/ghidra/GhidraRawProfileDigest.test.ts index 6621f5e142..3d5455a596 100644 --- a/src/ghidra/GhidraRawProfileDigest.test.ts +++ b/src/ghidra/GhidraRawProfileDigest.test.ts @@ -5,16 +5,23 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { silentLogger } from "../logger.js"; import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixture.js"; import { GhidraProvider } from "./GhidraProvider.js"; +import { ghidraBridgeSourcesDigest } from "./GhidraBridgeSources.js"; +import { createAnalysisProfile } from "../domain/analysisProfile.js"; -// Digests commit to the current shared policy, including the instruction -// status, load-image relocation-symbol, procedure search, byte-search and -// bit-field base-type, runtime value-flow budgets, function-boundary and inventory-search revisions that prevent reuse of snapshots with earlier -// results. -const RAW_DIGESTS = { - "6502": "6c4e88435526e8f0907b072600e3b121577962a36554e023dde4cf85113dd9f6", - "x86-disabled-analyzer": - "b2dc33d1932cbe71d72819428634927f3a2d8a2bbd54b6893f823ccb6b795d0b", -} as const; +// A profile digest is the digest of its committed parameters; these tests pin +// the parameters (semantic revisions and the bridge sources) rather than a +// literal hash that every bridge or marker change would have to re-record. +const expectCommittedProfile = ( + profile: ReturnType | null | undefined, +): void => { + if (profile == null) throw new Error("Expected an analysis profile"); + expect(profile.parameters).toMatchObject({ + bridge_sources_sha256: ghidraBridgeSourcesDigest(), + }); + expect(profile.digest).toBe( + createAnalysisProfile(profile.provider, profile.parameters).digest, + ); +}; const configFor = (environment: NodeJS.ProcessEnv) => { const parsed = parseConfig({ GHIDRA_INSTALL_DIR: "/ghidra", ...environment }); @@ -34,12 +41,12 @@ const raw = (architecture: "6502" | "x86", entryPoint: string): BinaryTarget => entryPointSource: entryPoint, }) as unknown as BinaryTarget; -describe("raw-binary analysis profile digest", () => { +describe("raw-binary analysis profile commitments", () => { it("commits the 6502 raw profile to the current shared policy", async () => { const ghidra = new GhidraProvider(configFor({}), silentLogger, {}, host); const resolved = await ghidra.resolveAnalysisProfile(raw("6502", "0x8001")); if (!resolved.ok) throw resolved.error; - expect(resolved.value.profile?.digest).toBe(RAW_DIGESTS["6502"]); + expectCommittedProfile(resolved.value.profile); expect(resolved.value.profile?.parameters).toMatchObject({ target_format: "raw-binary", loader: "BinaryLoader", @@ -72,9 +79,7 @@ describe("raw-binary analysis profile digest", () => { ); const resolved = await ghidra.resolveAnalysisProfile(raw("x86", "0x1000")); if (!resolved.ok) throw resolved.error; - expect(resolved.value.profile?.digest).toBe( - RAW_DIGESTS["x86-disabled-analyzer"], - ); + expectCommittedProfile(resolved.value.profile); expect(resolved.value.profile?.parameters).toMatchObject({ inventory_search_projection: "provider-inventory-facts-v1", value_flow_effect_scope: "pcode-load-store-explicit-v1",