From 1dc97d8cece2eb68c0e72feb56c7a2821f23237c Mon Sep 17 00:00:00 2001 From: Machen John Date: Sat, 31 Oct 2026 08:24:00 +0100 Subject: [PATCH] fix(ghidra): report a missing existing-project program as a configuration error (#8222) * fix(ghidra): report a missing existing-project program as a configuration error When REA_GHIDRA_PROJECT_PATH selects a project that has no root program with the selected name, Ghidra's headless analyzer aborts with "Requested project program file(s) not found: ". REA reported this as a generic execution_failure with rea doctor advice, and the reason was visible only in the captured diagnostics. Recognize that refusal in the operation-scoped launcher output, matching only the exact name REA passed to -process, and return a ConfigurationError for REA_GHIDRA_PROGRAM_NAME. The message says whether the name was configured or defaulted from the target file name, and the remediation explains how to select the exact root program. The selected project is unchanged and there is no fallback to a fresh import. Other launch failures keep their existing projection. Fixes #8216 * test(ghidra): assert missing-program guidance through its formatter Compare the projected setting constraint and remediation with ghidraMissingProgramGuidance instead of freezing explanatory phrases, following docs/testing.md. The program name and configured-or-defaulted origin are still checked independently. --- src/ghidra/GhidraProvider.behavior.test.ts | 100 +++++++++++++++++++- src/ghidra/GhidraProviderClient.ts | 103 +++++++++++++++++++-- 2 files changed, 193 insertions(+), 10 deletions(-) diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 95deccc7a0..b2765445b5 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -40,7 +40,10 @@ import { parseExecutableHeader } from "../domain/binaryTarget.js"; import { elf, thinMach } from "../domain/binaryTarget.fixture.js"; import { GhidraProvider } from "./GhidraProvider.js"; import type { GhidraTargetSnapshot } from "./GhidraClient.js"; -import type { GhidraProviderClientFactory } from "./GhidraProviderClient.js"; +import { + ghidraMissingProgramGuidance, + type GhidraProviderClientFactory, +} from "./GhidraProviderClient.js"; import type { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; import type { GhidraInstallationHost } from "./GhidraInstallation.js"; import { @@ -3294,6 +3297,101 @@ describe("Ghidra deadline guidance", () => { }); }); +describe("Ghidra missing project program", () => { + const startFailure = async ( + stdout: string, + environment: Readonly> = {}, + ) => { + const root = await mkdtemp(join(tmpdir(), "rea-missing-program-")); + onTestFinished(async () => { + await rm(root, { recursive: true, force: true }); + }); + await mkdir(join(root, "selected.rep")); + await writeFile(join(root, "selected.gpr"), "marker"); + const failure = new GhidraSessionError( + "start", + "Ghidra exited before the bridge was ready", + { + failure_kind: "start", + exit_code: 1, + failure_output: failureOutput(stdout), + }, + ); + const ghidra = provider( + installationHost(), + () => ({ + start: async () => err(failure), + callTool: async () => err(failure), + close: async () => ok(null), + }), + { + REA_GHIDRA_PROJECT_PATH: join(root, "selected.gpr"), + ...environment, + }, + ); + onTestFinished(async () => { + await ghidra.closeOwnedResources(); + }); + const target = executableTarget("elf", "x86_64"); + const profile = await ghidra.resolveAnalysisProfile(target); + if (!profile.ok || profile.value.profile === null) + throw new Error("Expected Ghidra analysis profile"); + const result = await ghidra + .createClient(target, profile.value.profile) + .execute("list_documents", {}); + if (result.ok) throw new Error("Expected a Ghidra start failure"); + return projectAnalysisError(result.error); + }; + const abort = (name: string) => + `ERROR Abort due to Headless analyzer error: Requested project program file(s) not found: ${name} (HeadlessAnalyzer) java.io.IOException: Requested project program file(s) not found: ${name}`; + + const expectMissingProgram = ( + projected: Awaited>, + programName: string, + configured: boolean, + ) => { + const guidance = ghidraMissingProgramGuidance(programName, configured); + expect(projected).toMatchObject({ + code: "configuration_invalid", + details: { + settings: [ + { + setting: "REA_GHIDRA_PROGRAM_NAME", + constraint: guidance.constraint, + }, + ], + }, + }); + expect(projected.remediation.action).toBe(guidance.remediationAction); + expect(projected.message).toContain(JSON.stringify(programName)); + }; + + it("reports the program name defaulted from the target as REA_GHIDRA_PROGRAM_NAME", async () => { + expectMissingProgram( + await startFailure(abort("fixture")), + "fixture", + false, + ); + }); + + it("reports an explicitly configured program name that the project lacks", async () => { + const projected = await startFailure(abort("program_gamma"), { + REA_GHIDRA_PROGRAM_NAME: "program_gamma", + }); + expectMissingProgram(projected, "program_gamma", true); + }); + + it("keeps the generic start failure when Ghidra names a different program", async () => { + const projected = await startFailure(abort("fixture_other")); + expect(projected.code).toBe("execution_failure"); + }); + + it("keeps the generic start failure for unrelated launch output", async () => { + const projected = await startFailure("ERROR Unable to lock project"); + expect(projected.code).toBe("execution_failure"); + }); +}); + describe("Ghidra analysis seeds and configured language", () => { const seedSession = async ( report: (sha256: string) => GhidraSeedReport | undefined, diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 5079a01d94..813d3dfc0a 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -63,6 +63,7 @@ import { type ProviderOperationHealth, } from "../domain/providerOperationHealth.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { err, ok, type Result } from "../domain/result.js"; import type { Logger } from "pino"; @@ -234,6 +235,10 @@ export const createGhidraProviderClient = (input: { }), ); const project = projectProfile.data; + const projection: GhidraProjection = { + limits: config, + programName: project?.program_name, + }; const seedProfile = ghidraSeedCommitmentSchema .optional() .safeParse(committedProfile.parameters.analysis_seeds); @@ -600,7 +605,7 @@ export const createGhidraProviderClient = (input: { projectSessionError( operation, started.error, - config, + projection, lostProviderHealth(), ), ); @@ -629,7 +634,7 @@ export const createGhidraProviderClient = (input: { targetPath: target.path, readSnapshot: client.readTargetSnapshot?.bind(client), projectFailure: (operation, failure) => - projectSessionError(operation, failure, config), + projectSessionError(operation, failure, projection), }) : undefined; const projectExecution = ( @@ -664,7 +669,7 @@ export const createGhidraProviderClient = (input: { projectSessionError( operation, called.error, - config, + projection, lostProviderHealth(), ), ); @@ -705,7 +710,12 @@ export const createGhidraProviderClient = (input: { client, languageOverride: importLanguage, mapSessionError: (failure) => - projectSessionError(operation, failure, config, lostProviderHealth()), + projectSessionError( + operation, + failure, + projection, + lostProviderHealth(), + ), nativeAotResult: derived, signal, }); @@ -777,7 +787,7 @@ export const createGhidraProviderClient = (input: { projectSessionError( operation, called.error, - config, + projection, lostProviderHealth(), ), ); @@ -821,7 +831,7 @@ export const createGhidraProviderClient = (input: { projectSessionError( operation, started.error, - config, + projection, lostProviderHealth(), ), ); @@ -978,16 +988,22 @@ type GhidraProjectionLimits = Pick< | "ghidraExistingProject" >; +/** Projection inputs: live configured limits and the program passed to `-process`. */ +interface GhidraProjection { + readonly limits: GhidraProjectionLimits; + readonly programName: string | undefined; +} + const projectSessionError = ( operation: AnalysisOperation, failure: GhidraSessionError, - limits: GhidraProjectionLimits, + projection: GhidraProjection, health?: ProviderOperationHealth, ): AnalysisError => { const primary = projectPrimarySessionError( operation, failure, - limits, + projection, health, ); return failure.cleanupFailure === undefined @@ -1002,7 +1018,7 @@ const projectSessionError = ( const projectPrimarySessionError = ( operation: AnalysisOperation, failure: GhidraSessionError, - limits: GhidraProjectionLimits, + { limits, programName }: GhidraProjection, health?: ProviderOperationHealth, ): AnalysisError => { const startupTimeoutMs = limits.ghidraStartupTimeoutMs; @@ -1155,6 +1171,15 @@ const projectPrimarySessionError = ( ? "Ghidra's output for this operation is incomplete. No OutOfMemoryError was confirmed; a memory cause cannot be ruled out." : undefined; if (memoryFailure !== undefined) return memoryFailure; + if ( + programName !== undefined && + output?.text.some((text) => reportsMissingProgram(text, programName)) + ) + return missingProjectProgram( + programName, + limits.ghidraExistingProject?.programName !== undefined, + context, + ); const killed = projectSigkill(operation, failure, context, { restart: exitedProviderMessage(operation, failure, health), uncertainty, @@ -1210,8 +1235,60 @@ const observedJvmFailureSchema = z.object({ interface GhidraFailureOutputView { readonly oomText: string | undefined; readonly complete: boolean | undefined; + readonly text: readonly string[]; } +/** Ghidra's headless refusal when `-process` names no program in the project. */ +const GHIDRA_MISSING_PROGRAM = "Requested project program file(s) not found: "; + +/** + * Match only the exact name REA passed to `-process`, so a longer program name + * or unrelated output cannot be read as this refusal. + */ +const reportsMissingProgram = (text: string, programName: string): boolean => + text.split(/\r?\n/u).some((line) => { + const needle = `${GHIDRA_MISSING_PROGRAM}${programName}`; + const at = line.indexOf(needle); + if (at === -1) return false; + const next = line.charAt(at + needle.length); + return next === "" || next === " "; + }); + +/** Guidance for a `-process` name that selects no root program in the project. */ +export const ghidraMissingProgramGuidance = ( + programName: string, + configured: boolean, +) => { + const origin = configured + ? "set by REA_GHIDRA_PROGRAM_NAME" + : "defaulted from the target file name because REA_GHIDRA_PROGRAM_NAME is unset"; + return { + constraint: `Ghidra found no root program named ${JSON.stringify(programName)} (${origin}) in the selected REA_GHIDRA_PROJECT_PATH project.`, + remediationAction: + "Set REA_GHIDRA_PROGRAM_NAME to the exact name of a root program in the selected project, in the environment that starts REA (your shell or the MCP client's registration), then restart REA and open the target again. REA did not modify the project or fall back to a fresh import.", + }; +}; + +/** The selected project is preserved; only the program choice is reported. */ +const missingProjectProgram = ( + programName: string, + configured: boolean, + context: { + readonly cause: GhidraSessionError; + readonly diagnostics: Readonly>; + }, +): ConfigurationError => { + const { constraint, remediationAction } = ghidraMissingProgramGuidance( + programName, + configured, + ); + return new ConfigurationError(constraint, { + ...context, + settings: [{ setting: "REA_GHIDRA_PROGRAM_NAME", constraint }], + remediationAction, + }); +}; + /** Read the scoped capture once; unavailable fields remain unconfirmed. */ const readGhidraFailureOutput = ( scoped: JsonValue | undefined, @@ -1228,6 +1305,14 @@ const readGhidraFailureOutput = ( oomText: observedLines.length === 0 ? undefined : observedLines.join("\n"), complete: typeof scoped.complete === "boolean" ? scoped.complete : undefined, + text: [scoped.stdout, scoped.stderr].flatMap((stream) => + stream !== null && + typeof stream === "object" && + !Array.isArray(stream) && + typeof stream.text === "string" + ? [stream.text] + : [], + ), }; };