From 42c276af1ec02473a7b6700a31292aae9e53d53e Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:03:10 +0000 Subject: [PATCH] fix(ghidra): report NativeAOT host and target refusals as unsupported, not failures With REA_GHIDRA_NATIVEAOT_JAR set on a host other than Linux, Ghidra analysis was refused before Ghidra started, as intended, but the refusal was a ProviderAdapterError: `execution_failure` with advice to retry and run doctor, neither of which can change the host. Adapters now say whether a refusal concerns the host or the target. A host refusal is `capability_unavailable` whose message and remediation are the adapter's reason (omit the JAR for ordinary analysis); a target refusal is `unsupported_target` with the target path. Missing or malformed JARs on a supported host remain provider failures. Fixes #1185 Co-Authored-By: Claude Opus 5.5 --- .../extensions/GhidraExtensions.test.ts | 69 +++++++++++++------ src/ghidra/extensions/GhidraExtensions.ts | 48 ++++++++++--- .../extensions/nativeaot/NativeAotAdapter.ts | 12 +++- 3 files changed, 94 insertions(+), 35 deletions(-) diff --git a/src/ghidra/extensions/GhidraExtensions.test.ts b/src/ghidra/extensions/GhidraExtensions.test.ts index b35e63f9cc..0b4b8eb7bd 100644 --- a/src/ghidra/extensions/GhidraExtensions.test.ts +++ b/src/ghidra/extensions/GhidraExtensions.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { parseConfig } from "../../config.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import type { BinaryTarget } from "../../domain/binaryTarget.js"; import { ghidraExtensionSchema, @@ -70,6 +71,50 @@ const result = (sha256: string): GhidraExtensionResult => ({ }, }); +describe("optional Ghidra extension refusals", () => { + it("rejects unsupported hosts and target architectures before opening files", async () => { + const host = await resolveGhidraExtensions( + config("/absent.jar"), + target, + "darwin", + ); + expect(host.ok).toBe(false); + if (!host.ok) + // A host restriction is not an execution failure to retry or repair. + expect(projectAnalysisError(host.error)).toMatchObject({ + code: "capability_unavailable", + retryable: false, + message: expect.stringContaining("verified on Linux only"), + remediation: { + action: expect.stringContaining("omit REA_GHIDRA_NATIVEAOT_JAR"), + }, + details: { + provider_id: "ghidra", + operation: "resolve_analysis_profile", + }, + }); + const architecture = await resolveGhidraExtensions( + config("/absent.jar"), + { + ...target, + architecture: "arm64", + availableArchitectures: ["arm64"], + }, + "linux", + ); + expect(architecture.ok).toBe(false); + if (!architecture.ok) + expect(projectAnalysisError(architecture.error)).toMatchObject({ + code: "unsupported_target", + details: { + operation: "resolve_analysis_profile", + path: target.path, + reason: expect.stringContaining("x86-64 ELF or native PE"), + }, + }); + }); +}); + describe("optional Ghidra extension boundary", () => { it("keeps ordinary profiles free of unconfigured extensions", async () => { expect(await resolveGhidraExtensions(config(), target, "linux")).toEqual({ @@ -136,25 +181,6 @@ describe("optional Ghidra extension boundary", () => { ).ok, ).toBe(false); }); - it("rejects unsupported hosts and target architectures before opening files", async () => { - expect( - (await resolveGhidraExtensions(config("/absent.jar"), target, "win32")) - .ok, - ).toBe(false); - expect( - ( - await resolveGhidraExtensions( - config("/absent.jar"), - { - ...target, - architecture: "arm64", - availableArchitectures: ["arm64"], - }, - "linux", - ) - ).ok, - ).toBe(false); - }); it.skipIf(process.platform === "win32")( "rejects a FIFO without requiring another process to open its writer", async () => { @@ -287,9 +313,8 @@ describe("Ghidra extension profile and producer validation", () => { expect(resolved.ok).toBe(false); if (!resolved.ok) expect(resolved.error).toMatchObject({ - diagnostics: { - reason: expect.stringContaining("inspect_managed_artifact"), - }, + _tag: "AnalysisUnsupportedTargetError", + reason: expect.stringContaining("inspect_managed_artifact"), }); }); it("does not substitute another artifact under an unchanged configured path", async () => { diff --git a/src/ghidra/extensions/GhidraExtensions.ts b/src/ghidra/extensions/GhidraExtensions.ts index 5ffff9b6ca..fb878b5220 100644 --- a/src/ghidra/extensions/GhidraExtensions.ts +++ b/src/ghidra/extensions/GhidraExtensions.ts @@ -5,7 +5,11 @@ import { isAbsolute, join } from "node:path"; import { z } from "zod"; import type { AppConfig } from "../../config.js"; import type { BinaryTarget } from "../../domain/binaryTarget.js"; -import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisUnsupportedTargetError, +} from "../../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { jsonObjectSchema } from "../../domain/jsonValue.js"; @@ -42,6 +46,12 @@ export const ghidraExtensionResultSchema = z.strictObject({ }); export type GhidraExtensionResult = z.infer; +/** Why an adapter cannot run here: on this host at all, or for this target. */ +export interface GhidraExtensionRefusal { + readonly constraint: "host" | "target"; + readonly reason: string; +} + /** Provider-local registration contract for independently replaceable analysis adapters. */ export interface GhidraExtensionAdapter { readonly id: string; @@ -50,7 +60,7 @@ export interface GhidraExtensionAdapter { unsupportedReason( target: BinaryTarget, platform: NodeJS.Platform, - ): string | null; + ): GhidraExtensionRefusal | null; validate(result: GhidraExtensionResult): string | null; readonly limitations: readonly string[]; } @@ -109,13 +119,8 @@ export const resolveGhidraExtensions = async ( if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); const configured = adapter.configuredPath(config); if (configured === undefined) continue; - const reason = adapter.unsupportedReason(target, platform); - if (reason !== null) - return err( - new ProviderAdapterError("ghidra", "resolve_analysis_profile", { - diagnostics: { extension: adapter.id, reason, path: configured }, - }), - ); + const refusal = adapter.unsupportedReason(target, platform); + if (refusal !== null) return err(extensionRefusalError(refusal, target)); try { const path = await realpath(configured); const bytes = await readJar(path); @@ -168,12 +173,33 @@ export const validateGhidraExtensionProfile = ( adapter.configuredPath(config) !== extension.configured_path ) return `Ghidra extension profile lacks matching explicit local configuration: ${extension.id}`; - const reason = adapter.unsupportedReason(target, platform); - if (reason !== null) return reason; + const refusal = adapter.unsupportedReason(target, platform); + if (refusal !== null) return refusal.reason; } return null; }; +/** + * A deliberate host or target refusal is not an execution failure: retrying + * cannot change it, and its reason already names the remedy. + */ +const extensionRefusalError = ( + refusal: GhidraExtensionRefusal, + target: BinaryTarget, +): AnalysisError => + refusal.constraint === "host" + ? new AnalysisCapabilityUnavailableError( + "ghidra", + "resolve_analysis_profile", + refusal.reason, + { userMessage: refusal.reason }, + ) + : new AnalysisUnsupportedTargetError( + "resolve_analysis_profile", + target.path, + refusal.reason, + ); + /** Snapshot committed extension bytes into the private runtime before Java loads them. */ export const snapshotGhidraExtensions = async ( extensions: readonly GhidraExtension[], diff --git a/src/ghidra/extensions/nativeaot/NativeAotAdapter.ts b/src/ghidra/extensions/nativeaot/NativeAotAdapter.ts index d7b30663cd..4893dfa27a 100644 --- a/src/ghidra/extensions/nativeaot/NativeAotAdapter.ts +++ b/src/ghidra/extensions/nativeaot/NativeAotAdapter.ts @@ -8,11 +8,19 @@ export const nativeAotAdapter: GhidraExtensionAdapter = { configuredPath: (config) => config.ghidraNativeAotJar, unsupportedReason: (target, platform) => platform !== "linux" - ? "NativeAOT recovery is verified on Linux only; omit REA_GHIDRA_NATIVEAOT_JAR for ordinary native analysis." + ? { + constraint: "host", + reason: + "NativeAOT recovery is verified on Linux only; omit REA_GHIDRA_NATIVEAOT_JAR for ordinary native analysis.", + } : !["elf", "pe"].includes(target.format) || target.architecture !== "x86_64" || target.managed === true - ? "NativeAOT recovery requires an x86-64 ELF or native PE target on Linux; PE/CLI and ReadyToRun assemblies use inspect_managed_artifact; omit REA_GHIDRA_NATIVEAOT_JAR for ordinary native analysis." + ? { + constraint: "target", + reason: + "NativeAOT recovery requires an x86-64 ELF or native PE target on Linux; PE/CLI and ReadyToRun assemblies use inspect_managed_artifact; omit REA_GHIDRA_NATIVEAOT_JAR for ordinary native analysis.", + } : null, validate: validateNativeAotReport, limitations: [