diff --git a/src/ghidra/GhidraProjectSnapshot.ts b/src/ghidra/GhidraProjectSnapshot.ts index 64105951db..75f85566e6 100644 --- a/src/ghidra/GhidraProjectSnapshot.ts +++ b/src/ghidra/GhidraProjectSnapshot.ts @@ -24,6 +24,7 @@ import { } from "../filesystem/RegularFile.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import type { JsonValue } from "../domain/jsonValue.js"; import type { GhidraProjectSelection } from "../config/ghidraExistingProject.js"; import { ghidraProgramNameSchema } from "../config/ghidraExistingProject.js"; @@ -59,9 +60,11 @@ export type GhidraProjectCommitment = z.infer< const selectionFailure = ( message: string, setting = "REA_GHIDRA_PROJECT_PATH", + diagnostics?: Readonly>, ): ConfigurationError => new ConfigurationError(message, { settings: [{ setting, constraint: message }], + ...(diagnostics === undefined ? {} : { diagnostics }), }); const entriesFor = (name: string) => [`${name}.gpr`, `${name}.rep`] as const; @@ -176,7 +179,14 @@ export const resolveGhidraProject = async ( // Admit the caller's exact marker before canonicalizing its parent. // Darwin realpath(file/..) can succeed despite native lookup rejecting // that traversal; hashing the canonical sibling would select a new input. - if (!(await lstat(selection.path)).isFile()) + const marker = await lstat(selection.path).catch(absentProjectEntry); + if (marker === undefined) + throw selectionFailure( + `Existing Ghidra project marker does not exist: ${selection.path}`, + "REA_GHIDRA_PROJECT_PATH", + { system_code: "ENOENT", syscall: "lstat" }, + ); + if (!marker.isFile()) throw selectionFailure( `Existing Ghidra project marker must be a regular file: ${selection.path}`, ); diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 911f280a7b..3c70f66f5c 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -4620,6 +4620,26 @@ it("reports every Windows existing-project capability unavailable before open", } }); +it("reports an unsupported existing-project target as an invalid REA_GHIDRA_PROJECT_PATH setting", async () => { + const ghidra = provider(installationHost(), undefined, { + REA_GHIDRA_PROJECT_PATH: join(process.cwd(), "selected.gpr"), + }); + try { + const resolved = await ghidra.resolveAnalysisProfile({ + ...executableTarget("elf", "x86_64"), + format: "dos-com", + } as unknown as BinaryTarget); + expect(resolved.ok).toBe(false); + if (resolved.ok) return; + expect(resolved.error).toMatchObject({ + _tag: "ConfigurationError", + settings: [{ setting: "REA_GHIDRA_PROJECT_PATH" }], + }); + } finally { + await ghidra.closeOwnedResources(); + } +}); + it("keeps saved project reports as shared historical Evidence without applying fresh overrides", async () => { const root = await mkdtemp(join(tmpdir(), "rea-history-project-")); await mkdir(join(root, "history.rep")); diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index e032ba2683..6666783592 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -3,7 +3,7 @@ import { ghidraPinnedLanguage, resolveGhidraAnalysisProfile, } from "./GhidraAnalysisProfile.js"; -import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; import { resolveGhidraProject } from "./GhidraProjectSnapshot.js"; import { DeferredAnalysisClient } from "../providers/DeferredAnalysisClient.js"; import type { ProviderInspectionScope } from "../application/ProviderInspectionScope.js"; @@ -419,14 +419,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { isMachOFormat(target.format) ) ) - return err( - new ProviderAdapterError("ghidra", "resolve_analysis_profile", { - diagnostics: { - reason: - "Existing-project mode supports native ELF/PE/Mach-O programs on POSIX without fresh-import loader preparation or pinned language overrides; use fresh import for this target/host.", - }, - }), - ); + return err(existingProjectUnsupported()); const project = await resolveGhidraProject( this.config.ghidraExistingProject, target, @@ -671,6 +664,15 @@ export class GhidraProvider implements AnalysisProviderCandidate { } } +/** The selected project, not the target or provider, is what this host cannot serve. */ +const existingProjectUnsupported = (): ConfigurationError => { + const constraint = + "Existing-project mode supports native ELF/PE/Mach-O programs on POSIX without fresh-import loader preparation or pinned language overrides; unset REA_GHIDRA_PROJECT_PATH to use fresh import for this target/host."; + return new ConfigurationError(constraint, { + settings: [{ setting: "REA_GHIDRA_PROJECT_PATH", constraint }], + }); +}; + const providerFailureText = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); diff --git a/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts b/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts index 972f3f1e8c..d429cbf689 100644 --- a/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts +++ b/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts @@ -196,6 +196,21 @@ it.skipIf(process.platform === "win32")( }, ); +it("reports a missing project marker as an invalid REA_GHIDRA_PROJECT_PATH selection", async () => { + const root = await createTestTempDirectory("rea-ghidra-project-missing-"); + await expect( + resolveGhidraProject( + { path: join(root, "missing", "app.gpr") }, + { path: join(root, "target") }, + resources, + ), + ).rejects.toMatchObject({ + _tag: "ConfigurationError", + settings: [{ setting: "REA_GHIDRA_PROJECT_PATH" }], + diagnostics: { system_code: "ENOENT" }, + }); +}); + it.skipIf(process.platform === "win32")( "follows native parent symlinks and preserves missing marker failures", async () => {