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 <[email protected]>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-09 04:03:10 +00:00
1 parent e6abaefbbc
commit 42c276af1e
3 files changed
+94 -35

No files matched your search

+47 -22
View File
@@ -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 () => {
+37 -11
View File
@@ -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<typeof ghidraExtensionResultSchema>;
/** 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[],
@@ -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: [