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] + : [], + ), }; };