diff --git a/bridge/ghidra/ReaGhidraAnalysisOptions.java b/bridge/ghidra/ReaGhidraAnalysisOptions.java index 6ecafb19dd..8fda062309 100644 --- a/bridge/ghidra/ReaGhidraAnalysisOptions.java +++ b/bridge/ghidra/ReaGhidraAnalysisOptions.java @@ -1,38 +1,91 @@ +import java.util.HashSet; import java.util.Map; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import ghidra.app.plugin.core.analysis.AutoAnalysisManager; +import ghidra.app.services.Analyzer; import ghidra.app.util.headless.HeadlessScript; +import ghidra.program.model.listing.Program; +import ghidra.util.classfinder.ClassSearcher; -/** Disable the named auto-analyzers before default auto-analysis. */ +/** + * Disable the named auto-analyzers before default auto-analysis. + * + * REA_GHIDRA_DISABLED_ANALYZERS is process-wide, but Ghidra registers only the + * installed analyzers whose canAnalyze(program) accepts this program. A name an + * installed analyzer provides without being registered here is not applicable + * and is recorded instead of rejected; a name no installed analyzer provides + * aborts the import. The outcome is stored as a program property that the + * bridge reports during its handshake. + */ public final class ReaGhidraAnalysisOptions extends HeadlessScript { + static final String REPORT_OPTION = "REA disabled analyzers"; + private static final String SETTING = "REA_GHIDRA_DISABLED_ANALYZERS"; + @Override public void run() throws Exception { String[] arguments = getScriptArgs(); if (arguments.length != 1 || arguments[0].isBlank()) { throw new IllegalArgumentException( - "ReaGhidraAnalysisOptions requires one comma-separated analyzer-name argument"); + "ReaGhidraAnalysisOptions requires one comma-separated " + SETTING + " argument"); } String[] names = arguments[0].split(","); + boolean[] registered = new boolean[names.length]; AutoAnalysisManager analysis = AutoAnalysisManager.getAnalysisManager(currentProgram); Map options = getCurrentAnalysisOptionsAndValues(currentProgram); - for (String requestedName : names) { + Set installed = null; + for (int index = 0; index < names.length; index++) { monitor.checkCancelled(); - String name = requestedName.trim(); - if (analysis.getAnalyzer(name) == null || !options.containsKey(name)) { - throw new IllegalArgumentException("Unknown Ghidra analyzer for this program: " + name); + String name = names[index].trim(); + names[index] = name; + if (analysis.getAnalyzer(name) != null) { + if (!options.containsKey(name)) { + throw new IllegalStateException(SETTING + " names " + name + + ", which Ghidra registered for this program without an enablement option"); + } + registered[index] = true; + continue; + } + if (installed == null) installed = installedAnalyzerNames(); + if (!installed.contains(name)) { + throw new IllegalArgumentException(SETTING + " names " + name + + ", which is not an analyzer in this Ghidra installation"); } } - for (String requestedName : names) { + for (int index = 0; index < names.length; index++) { monitor.checkCancelled(); - setAnalysisOption(currentProgram, requestedName.trim(), "false"); + if (registered[index]) setAnalysisOption(currentProgram, names[index], "false"); } options = getCurrentAnalysisOptionsAndValues(currentProgram); - for (String requestedName : names) { + JsonArray analyzers = new JsonArray(); + for (int index = 0; index < names.length; index++) { monitor.checkCancelled(); - String name = requestedName.trim(); - if (!"false".equals(options.get(name))) { - throw new IllegalStateException("Ghidra analyzer remained enabled: " + name); + String name = names[index]; + if (registered[index] && !"false".equals(options.get(name))) { + throw new IllegalStateException(SETTING + " names " + name + + ", which remained enabled for this program"); } + JsonObject analyzer = new JsonObject(); + analyzer.addProperty("name", name); + analyzer.addProperty("status", registered[index] ? "disabled" : "not_applicable"); + analyzers.add(analyzer); } + JsonObject report = new JsonObject(); + report.addProperty("format", "rea-ghidra-disabled-analyzers-v1"); + report.add("analyzers", analyzers); + currentProgram.getOptions(Program.PROGRAM_INFO).setString(REPORT_OPTION, report.toString()); + println("REA disabled analyzers: " + report); + } + + /** The same analyzer universe AutoAnalysisManager filters by canAnalyze(program). */ + private static Set installedAnalyzerNames() { + Set names = new HashSet<>(); + for (Analyzer analyzer : ClassSearcher.getInstances(Analyzer.class)) { + names.add(analyzer.getName()); + } + return names; } } diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 35a0902dfa..b091221c7a 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -523,6 +523,11 @@ public final class ReaGhidraBridge extends HeadlessScript { String seeds = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO) .getString("REA analysis seeds", null); if (seeds != null) result.add("analysis_seeds", JsonParser.parseString(seeds)); + // Written by ReaGhidraAnalysisOptions before auto-analysis; REA checks it + // against the committed profile. + String disabledAnalyzers = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO) + .getString("REA disabled analyzers", null); + if (disabledAnalyzers != null) result.add("disabled_analyzers", JsonParser.parseString(disabledAnalyzers)); // Written by ReaGhidraMipsSoftFloat after analysis; REA requires it // exactly when the committed profile declares soft-float storage. String softFloat = currentProgram.getOptions(ghidra.program.model.listing.Program.PROGRAM_INFO) diff --git a/docs/installation.md b/docs/installation.md index 3cafa9a797..d6e1bdb4b3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1127,17 +1127,22 @@ override in `analyzer_preset` (for example open when its committed profile and the setting differ. A name starts with a letter or digit and contains only letters, digits, spaces, periods, underscores, and hyphens; at most 32 names are admitted, each at most 120 characters. -Names must identify analyzers registered for the selected program. Unknown names -or options that remain enabled abort the headless import before auto-analysis -and the bridge, so an unsuccessful override cannot produce a session claiming -that the analyzer was disabled. The setting applies to every import in the REA -server. A format-specific analyzer may not be registered for another target, so -the same override can prevent that target from opening. For mixed-target work, -omit the override or use a separate REA server configured for the affected -format. Changing the environment requires restarting the server; reopening a -target alone keeps the current setting. For Windows PE resource analysis, the -exact name is `WindowsResourceReference`. Disabling an analyzer skips the facts -it would have produced. + +The setting applies to every import in the REA server, but Ghidra registers +format-specific analyzers, such as `Objective-C Message Analyzer` or +`Demangler Swift`, only for programs they can analyze. A name that the Ghidra +installation provides but did not register for the selected program is not +applicable: the import continues, the profile still commits the configured +names, and result limitations report the applied preset with that name marked, +for example `ghidra-default+disabled:Objective-C Message Analyzer(n/a)`. A name +that no installed analyzer provides, or an option that remains enabled, aborts +the headless import before auto-analysis and the bridge with an error naming +`REA_GHIDRA_DISABLED_ANALYZERS` and the analyzer, so a misspelled override +cannot produce a session claiming that the analyzer was disabled. Changing the +environment requires restarting the server; reopening a target alone keeps the +current setting. For Windows PE resource analysis, the exact name is +`WindowsResourceReference`. Disabling an analyzer skips the facts it would have +produced. ### Ghidra heap and CPU controls diff --git a/docs/testing.md b/docs/testing.md index 7ff6fbd079..4d16708475 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -768,16 +768,16 @@ installation (`application.java.min` through `application.java.max`). Current 12.1 releases require JDK 21 or newer and set no maximum. The lanes below still prove behavior on the verified Ghidra 12.1.4 and JDK 21 build. -| Ghidra lane | Supported runner/target | Additional local tools | -| ------------------------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 | -| `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback and unknown-name rejection | Ghidra 12.1.4 and full JDK 21 | -| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 | -| `npm run verify:ghidra:riscv` | Linux/macOS x64/arm64 host; source-owned RV32/RV64 ELF through CLI and MCP | RISC-V Clang, LLD, LLVM readelf, Ghidra 12.1.4, and full JDK 21 | -| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 | -| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 | -| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites | -| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE; `-- --x86` selects native x86 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact | +| Ghidra lane | Supported runner/target | Additional local tools | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 | +| `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback, not-applicable and unknown analyzer names | Ghidra 12.1.4 and full JDK 21 | +| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 | +| `npm run verify:ghidra:riscv` | Linux/macOS x64/arm64 host; source-owned RV32/RV64 ELF through CLI and MCP | RISC-V Clang, LLD, LLVM readelf, Ghidra 12.1.4, and full JDK 21 | +| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 | +| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 | +| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites | +| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE; `-- --x86` selects native x86 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact | The optional Swift lane compiles a source-owned fixture and checks modern and provider-demangled procedure names through real stdio MCP, with and without the diff --git a/scripts/verify/ghidra/seeds.mjs b/scripts/verify/ghidra/seeds.mjs index e9f0435b55..0e2447489a 100644 --- a/scripts/verify/ghidra/seeds.mjs +++ b/scripts/verify/ghidra/seeds.mjs @@ -64,6 +64,33 @@ try { disabled, ); + // Installed, but Ghidra registers it only for Objective-C 2 Mach-O programs. + const formatSpecificAnalyzer = "Objective-C Message Analyzer"; + const notApplicable = await runHeadless( + target, + "analyzer-regression-not-applicable", + [ + "-preScript", + "ReaGhidraAnalysisOptions.java", + `${analyzer},${formatSpecificAnalyzer}`, + "-postScript", + "ReaAnalyzerOptionsRegressionProbe.java", + analyzer, + ], + ); + assert.ok( + notApplicable.includes( + `REA_ANALYZER_OPTIONS_REPORT ${JSON.stringify({ + format: "rea-ghidra-disabled-analyzers-v1", + analyzers: [ + { name: analyzer, status: "disabled" }, + { name: formatSpecificAnalyzer, status: "not_applicable" }, + ], + })}`, + ), + notApplicable, + ); + const unknownAnalyzer = "REA Unknown Analyzer"; const rejected = await runHeadless(target, "analyzer-regression-unknown", [ "-preScript", @@ -75,7 +102,7 @@ try { ]); assert.ok( rejected.includes( - `Unknown Ghidra analyzer for this program: ${unknownAnalyzer}`, + `REA_GHIDRA_DISABLED_ANALYZERS names ${unknownAnalyzer}, which is not an analyzer in this Ghidra installation`, ), rejected, ); @@ -93,7 +120,7 @@ try { await rm(workspace, { recursive: true, force: true }); } console.log( - "Real Ghidra pre-script regression passed: descending function order, out-of-range seed, BSS label, BSS code rejection, analyzer option readback, unknown-analyzer abort before post-scripts, and owned-process cleanup.", + "Real Ghidra pre-script regression passed: descending function order, out-of-range seed, BSS label, BSS code rejection, analyzer option readback, not-applicable analyzer report, unknown-analyzer abort before post-scripts, and owned-process cleanup.", ); async function runHeadless(target, project, scriptArguments) { diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index a4506dd97e..6bb2eb3dee 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -13,6 +13,7 @@ import { 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 type { GhidraInstallationInspection } from "./GhidraInstallation.js"; import { isLegacyMipsAdmission, @@ -108,7 +109,7 @@ export const resolveGhidraAnalysisProfile = async ( disabledAnalyzers === undefined || disabledAnalyzers.length === 0 ? { analyzer_preset: "ghidra-default" } : { - analyzer_preset: `ghidra-default+disabled:${disabledAnalyzers.join(",")}`, + analyzer_preset: ghidraDisabledAnalyzerPreset(disabledAnalyzers), analysis_options: { disabled_analyzers: [...disabledAnalyzers] }, }; if (target.format === "raw-binary") { diff --git a/src/ghidra/GhidraDisabledAnalyzers.ts b/src/ghidra/GhidraDisabledAnalyzers.ts new file mode 100644 index 0000000000..d280fffec6 --- /dev/null +++ b/src/ghidra/GhidraDisabledAnalyzers.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +import type { AnalysisOperation } from "../application/AnalysisProvider.js"; +import { + MAX_GHIDRA_DISABLED_ANALYZER_NAME_LENGTH, + MAX_GHIDRA_DISABLED_ANALYZER_NAMES, +} from "../config/ghidraDisabledAnalyzers.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; + +/** Analyzer-options report format admitted by this adapter. */ +const GHIDRA_DISABLED_ANALYZER_REPORT_FORMAT = + "rea-ghidra-disabled-analyzers-v1"; + +/** + * What the pre-analysis options script did with each configured name, in + * configured order, as reported by the bridge handshake. `not_applicable` + * names an installed analyzer Ghidra did not register for this program. + */ +export const ghidraDisabledAnalyzerReportSchema = z.strictObject({ + format: z.literal(GHIDRA_DISABLED_ANALYZER_REPORT_FORMAT), + analyzers: z + .array( + z.strictObject({ + name: z.string().min(1).max(MAX_GHIDRA_DISABLED_ANALYZER_NAME_LENGTH), + status: z.enum(["disabled", "not_applicable"]), + }), + ) + .min(1) + .max(MAX_GHIDRA_DISABLED_ANALYZER_NAMES), +}); +export type GhidraDisabledAnalyzerReport = z.infer< + typeof ghidraDisabledAnalyzerReportSchema +>; + +/** The `analyzer_preset` value for a non-empty disabled-analyzer list. */ +export const ghidraDisabledAnalyzerPreset = ( + entries: readonly string[], +): string => `ghidra-default+disabled:${entries.join(",")}`; + +/** Fail closed when the session did not account for exactly the committed names. */ +export const ghidraDisabledAnalyzerFailure = ( + committed: readonly string[] | undefined, + report: GhidraDisabledAnalyzerReport | undefined, + operation: AnalysisOperation, +): ProviderAdapterError | undefined => { + if (committed === undefined && report === undefined) return undefined; + const reason = + committed === undefined + ? "The Ghidra session reported REA_GHIDRA_DISABLED_ANALYZERS outcomes that the profile did not commit." + : report === undefined + ? "The Ghidra session did not report the outcome of the committed REA_GHIDRA_DISABLED_ANALYZERS." + : report.analyzers.length !== committed.length || + report.analyzers.some( + ({ name }, index) => name !== committed[index], + ) + ? "The Ghidra session reported REA_GHIDRA_DISABLED_ANALYZERS outcomes for analyzers that differ from the committed profile." + : undefined; + return reason === undefined + ? undefined + : new ProviderAdapterError("ghidra", operation, { + diagnostics: { reason }, + }); +}; + +/** Name configured analyzers that Ghidra did not register for this program. */ +export const ghidraDisabledAnalyzerLimitations = ( + report: GhidraDisabledAnalyzerReport | undefined, +): readonly string[] => + report?.analyzers.some(({ status }) => status === "not_applicable") === true + ? [ + `Applied analyzer preset: ${ghidraDisabledAnalyzerPreset( + report.analyzers.map(({ name, status }) => + status === "not_applicable" ? `${name}(n/a)` : name, + ), + )}. Ghidra did not register the (n/a) analyzers for this program, so REA_GHIDRA_DISABLED_ANALYZERS had nothing to disable for them; the analysis profile still commits every configured name.`, + ] + : []; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 6d98b80854..7133464162 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -7,6 +7,7 @@ import { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js import { ok as javaProbeOk } from "../domain/result.js"; import type { GhidraJavaObservation } from "./GhidraJavaProbe.js"; import type { GhidraSeedReport } from "./GhidraAnalysisSeeds.js"; +import type { GhidraDisabledAnalyzerReport } from "./GhidraDisabledAnalyzers.js"; import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; // Fake-backed provider coverage; real Ghidra verification lives in @@ -3150,22 +3151,32 @@ describe("Ghidra analysis seeds and configured language", () => { }); }); -describe("Ghidra disabled analyzer options", () => { - const configuredProvider = ( - environment: Record, - clientFactory?: GhidraProviderClientFactory, - ) => { - const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL, ...environment }); - if (!config.ok) throw config.error; - return new GhidraProvider( - config.value, - silentLogger, - {}, - installationHost(), - clientFactory, - ); - }; +const configuredProvider = ( + environment: Record, + clientFactory?: GhidraProviderClientFactory, +) => { + const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL, ...environment }); + if (!config.ok) throw config.error; + return new GhidraProvider( + config.value, + silentLogger, + {}, + installationHost(), + clientFactory, + ); +}; +const analyzerReport = ( + ...analyzers: readonly (readonly [ + name: string, + status: GhidraDisabledAnalyzerReport["analyzers"][number]["status"], + ])[] +): GhidraDisabledAnalyzerReport => ({ + format: "rea-ghidra-disabled-analyzers-v1", + analyzers: analyzers.map(([name, status]) => ({ name, status })), +}); + +describe("Ghidra disabled analyzer options", () => { it("records the configured analyzer options in the analysis profile", async () => { const ghidra = configuredProvider({ REA_GHIDRA_DISABLED_ANALYZERS: @@ -3195,7 +3206,16 @@ describe("Ghidra disabled analyzer options", () => { (options) => { launchers.push(options.launcher as GhidraHeadlessLauncher); return { - start: () => Promise.resolve(ok(sessionInfo())), + start: () => + Promise.resolve( + ok({ + ...sessionInfo(), + disabled_analyzers: analyzerReport([ + "Objective-C Message Analyzer", + "not_applicable", + ]), + }), + ), callTool: () => Promise.resolve(ok(null)), close: () => Promise.resolve(ok(null)), }; @@ -3221,7 +3241,16 @@ describe("Ghidra disabled analyzer options", () => { (options) => { launchers.push(options.launcher as GhidraHeadlessLauncher); return { - start: () => Promise.resolve(ok(sessionInfo())), + start: () => + Promise.resolve( + ok({ + ...sessionInfo(), + disabled_analyzers: analyzerReport([ + "Decompiler Switch Analysis", + "disabled", + ]), + }), + ), callTool: () => Promise.resolve(ok(null)), close: () => Promise.resolve(ok(null)), }; @@ -3301,6 +3330,134 @@ describe("Ghidra disabled analyzer options", () => { }); }); +describe("Ghidra disabled analyzer applicability per program", () => { + const analyzerSession = async ( + setting: string | undefined, + report: GhidraDisabledAnalyzerReport | undefined, + ) => { + const counts = { starts: 0, calls: 0, closes: 0 }; + const ghidra = configuredProvider( + setting === undefined ? {} : { REA_GHIDRA_DISABLED_ANALYZERS: setting }, + () => ({ + start: () => { + counts.starts++; + return Promise.resolve( + ok({ + ...sessionInfo(), + ...(report === undefined ? {} : { disabled_analyzers: report }), + }), + ); + }, + callTool: () => { + counts.calls++; + return Promise.resolve( + ok([ + { + address: "0x1000", + value: "fixture_main", + procedure: { + external: false, + thunk: false, + thunk_target: null, + }, + }, + ]), + ); + }, + close: () => { + counts.closes++; + return Promise.resolve(ok(null)); + }, + }), + ); + const target = executableTarget("elf", "x86_64"); + const resolved = await ghidra.resolveAnalysisProfile(target); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("expected a bound Ghidra profile"); + return { + counts, + profile: resolved.value.profile, + client: ghidra.createClient(target, resolved.value.profile), + }; + }; + + it("adds no limitation when every configured analyzer was disabled", async () => { + const { client } = await analyzerSession( + "Decompiler Switch Analysis", + analyzerReport(["Decompiler Switch Analysis", "disabled"]), + ); + const health = await client.execute("health", {}); + if (!health.ok) throw health.error; + expect(health.value.limitations).not.toEqual( + expect.arrayContaining([expect.stringContaining("(n/a)")]), + ); + await client.close(); + }); + + it("opens a program for which a configured analyzer is not applicable", async () => { + const { counts, profile, client } = await analyzerSession( + "Decompiler Switch Analysis,Objective-C Message Analyzer", + analyzerReport( + ["Decompiler Switch Analysis", "disabled"], + ["Objective-C Message Analyzer", "not_applicable"], + ), + ); + const procedures = await client.execute("list_procedures", {}); + expect(counts.calls).toBe(1); + if (!procedures.ok) throw procedures.error; + expect(procedures.value.analysisProfile?.parameters).toMatchObject({ + analyzer_preset: + "ghidra-default+disabled:Decompiler Switch Analysis,Objective-C Message Analyzer", + }); + expect(procedures.value.analysisProfile?.digest).toBe(profile.digest); + expect(procedures.value.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "Applied analyzer preset: ghidra-default+disabled:Decompiler Switch Analysis,Objective-C Message Analyzer(n/a).", + ), + ]), + ); + await client.close(); + }); + + it.each([ + ["no analyzer report", undefined], + [ + "a report for other analyzers", + analyzerReport(["Decompiler Switch Analysis", "not_applicable"]), + ], + [ + "a report missing a configured analyzer", + analyzerReport(["Objective-C Message Analyzer", "not_applicable"]), + ], + ])("fails closed on %s before any analysis call", async (_label, report) => { + const { counts, client } = await analyzerSession( + "Objective-C Message Analyzer,Demangler Swift", + report, + ); + const failed = await client.execute("list_procedures", {}); + expect(failed.ok).toBe(false); + if (!failed.ok) { + expect(failed.error._tag).toBe("ProviderAdapterError"); + expect(failed.error.diagnostics?.reason).toEqual( + expect.stringContaining("REA_GHIDRA_DISABLED_ANALYZERS"), + ); + } + expect(counts).toEqual({ starts: 1, calls: 0, closes: 1 }); + }); + + it("fails closed on an analyzer report the profile did not commit", async () => { + const { counts, client } = await analyzerSession( + undefined, + analyzerReport(["Objective-C Message Analyzer", "not_applicable"]), + ); + const failed = await client.execute("health", {}); + expect(failed.ok).toBe(false); + if (!failed.ok) expect(failed.error._tag).toBe("ProviderAdapterError"); + expect(counts).toEqual({ starts: 1, calls: 0, closes: 1 }); + }); +}); + describe("Ghidra queries after the owned provider is lost", () => { const lostProviderSession = async ( state: "exited" | "unreachable" | "idle", diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index c114a89a8f..31bc26b910 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -24,6 +24,10 @@ import { sameGhidraDisabledAnalyzers, sameGhidraLanguageOverride, } from "./GhidraAnalysisProfile.js"; +import { + ghidraDisabledAnalyzerFailure, + ghidraDisabledAnalyzerLimitations, +} from "./GhidraDisabledAnalyzers.js"; import { ghidraPinnedMipsLanguage } from "./GhidraMipsProfile.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; @@ -296,6 +300,8 @@ export const createGhidraProviderClient = (input: { const softFloatLimitations: string[] = [ ...ghidraMipsSoftFloatLimitations(softFloat), ]; + // Replaced by the reported per-program outcome once the handshake is checked. + const disabledAnalyzerLimitations: string[] = []; const rawLanguage = target.format === "raw-binary" ? ghidraRawBinaryLanguage(target.architecture) @@ -449,6 +455,11 @@ export const createGhidraProviderClient = (input: { operation, ) ?? ghidraSeedFailure(seeds, info.analysis_seeds, operation) ?? + ghidraDisabledAnalyzerFailure( + disabledAnalyzers, + info.disabled_analyzers, + operation, + ) ?? (softFloatReason === undefined ? undefined : new ProviderAdapterError("ghidra", operation, { @@ -469,6 +480,12 @@ export const createGhidraProviderClient = (input: { info.mips_soft_float_storage, ), ); + if (extensionFailure === undefined) + disabledAnalyzerLimitations.splice( + 0, + disabledAnalyzerLimitations.length, + ...ghidraDisabledAnalyzerLimitations(info.disabled_analyzers), + ); if (extensionFailure === undefined) return undefined; const closed = await client.close(); return closed.ok @@ -507,6 +524,7 @@ export const createGhidraProviderClient = (input: { ...softFloatLimitations, ...ghidraExtensionLimitations(extensions), ...seedLimitations, + ...disabledAnalyzerLimitations, ...pdbLimitations, ...(releaseLimitation === undefined ? [] : [releaseLimitation]), ]; @@ -639,6 +657,7 @@ export const createGhidraProviderClient = (input: { target.format === "pe" || extensions.length > 0 || seeds !== undefined || + disabledAnalyzers !== undefined || softFloat ) { const started = await startSession(operation, options); diff --git a/src/ghidra/GhidraSessionValues.test.ts b/src/ghidra/GhidraSessionValues.test.ts index 84a5c3653d..02d1cdda70 100644 --- a/src/ghidra/GhidraSessionValues.test.ts +++ b/src/ghidra/GhidraSessionValues.test.ts @@ -140,6 +140,43 @@ describe("Ghidra MIPS soft-float storage report", () => { }); }); +describe("Ghidra disabled analyzer report", () => { + const valid = { + format: "rea-ghidra-disabled-analyzers-v1", + analyzers: [ + { name: "Decompiler Switch Analysis", status: "disabled" }, + { name: "Objective-C Message Analyzer", status: "not_applicable" }, + ], + }; + it("accepts disabled and not-applicable outcomes in configured order", () => { + const parsed = parseGhidraSessionInfo( + { ...session(), disabled_analyzers: valid }, + expected, + ); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.disabled_analyzers).toEqual(valid); + }); + it.each([ + [ + "an unknown analyzer reported as accepted", + { + ...valid, + analyzers: [{ name: "REA Unknown Analyzer", status: "unknown" }], + }, + ], + ["an empty outcome list", { ...valid, analyzers: [] }], + ["another report format", { ...valid, format: "rea-ghidra-seeds-v1" }], + ["an undeclared field", { ...valid, skipped: [] }], + ])("rejects %s", (_label, report) => { + expect( + parseGhidraSessionInfo( + { ...session(), disabled_analyzers: report }, + expected, + ).ok, + ).toBe(false); + }); +}); + describe("Ghidra mutation handshake", () => { it("requires the exact mutation authority and rejects duplicate capabilities", () => { const value = session(); diff --git a/src/ghidra/GhidraSessionValues.ts b/src/ghidra/GhidraSessionValues.ts index b699d273dd..4507abe0ff 100644 --- a/src/ghidra/GhidraSessionValues.ts +++ b/src/ghidra/GhidraSessionValues.ts @@ -1,4 +1,5 @@ import { ghidraSeedReportSchema } from "./GhidraAnalysisSeeds.js"; +import { ghidraDisabledAnalyzerReportSchema } from "./GhidraDisabledAnalyzers.js"; import { z } from "zod"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -83,6 +84,7 @@ const sessionInfoSchema = z .optional(), analysis_extensions: z.array(ghidraExtensionResultSchema).optional(), analysis_seeds: ghidraSeedReportSchema.optional(), + disabled_analyzers: ghidraDisabledAnalyzerReportSchema.optional(), mips_soft_float_storage: z .object({ policy: z.literal(GHIDRA_MIPS_SOFT_FLOAT_STORAGE), diff --git a/tests/conformance/ghidra/ReaAnalyzerOptionsRegressionProbe.java b/tests/conformance/ghidra/ReaAnalyzerOptionsRegressionProbe.java index 704e6339cd..91a8b94b01 100644 --- a/tests/conformance/ghidra/ReaAnalyzerOptionsRegressionProbe.java +++ b/tests/conformance/ghidra/ReaAnalyzerOptionsRegressionProbe.java @@ -1,4 +1,5 @@ import ghidra.app.util.headless.HeadlessScript; +import ghidra.program.model.listing.Program; /** Independent readback oracle for the production analyzer-options pre-script. */ public final class ReaAnalyzerOptionsRegressionProbe extends HeadlessScript { @@ -15,5 +16,7 @@ public final class ReaAnalyzerOptionsRegressionProbe extends HeadlessScript { throw new IllegalStateException("Analyzer option is not disabled: " + name); } println("REA_ANALYZER_OPTIONS_DISABLED_OK " + name); + println("REA_ANALYZER_OPTIONS_REPORT " + + currentProgram.getOptions(Program.PROGRAM_INFO).getString("REA disabled analyzers", null)); } }