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:
1 parent
e6abaefbbc
commit
42c276af1e
3 files changed
+94
-35
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in new issue
Block a user