diff --git a/src/ghidra/GhidraProjectSnapshot.ts b/src/ghidra/GhidraProjectSnapshot.ts index 75f85566e6..6257dbc0c4 100644 --- a/src/ghidra/GhidraProjectSnapshot.ts +++ b/src/ghidra/GhidraProjectSnapshot.ts @@ -23,11 +23,16 @@ import { RegularFileChangedError, } from "../filesystem/RegularFile.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; +import { AnalysisArtifactChangedError } from "../domain/analysisErrorCore.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"; +/** Reason for a selected project whose content no longer matches its committed profile. */ +export const GHIDRA_PROJECT_PROFILE_MISMATCH = + "Selected Ghidra project content no longer matches the profile committed by open_binary; when it changed is not established"; + /** Classify file cancellation/cleanup before the generic provider boundary. */ const projectFileOperation = async ( path: string, @@ -296,7 +301,13 @@ export const copyExistingGhidraProject = async ( signal, )) !== expectedSha256 ) - throw new RegularFileChangedError(sourcePath); + // A digest mismatch shows only that the project differs from its + // committed profile, not when it changed relative to this copy. + throw new AnalysisArtifactChangedError( + "open_binary", + sourcePath, + GHIDRA_PROJECT_PROFILE_MISMATCH, + ); } }); }; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 369d74d014..5663f10732 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -75,7 +75,12 @@ import { ghidraStartupTimeoutGuidance, type GhidraProviderClientFactory, } from "./GhidraProviderClient.js"; -import type { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; +import { + GhidraLaunchError, + type GhidraHeadlessLauncher, +} from "./GhidraLauncher.js"; +import { AnalysisArtifactChangedError } from "../domain/analysisErrorCore.js"; +import { GHIDRA_PROJECT_PROFILE_MISMATCH } from "./GhidraProjectSnapshot.js"; import type { GhidraInstallationHost } from "./GhidraInstallation.js"; import { GHIDRA_MUTATING_OPERATIONS, @@ -4243,6 +4248,79 @@ describe("Ghidra missing project program", () => { }); }); +describe("Ghidra changed project", () => { + const launchFailure = async (cause: unknown) => { + const root = await mkdtemp(join(tmpdir(), "rea-changed-project-")); + 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 headless launch failed", + { failure_kind: "start" }, + { + cause: new GhidraLaunchError("Ghidra headless launch failed", { + cause, + }), + }, + ); + const ghidra = provider( + installationHost(), + () => ({ + start: async () => err(failure), + callTool: async () => err(failure), + close: async () => ok(null), + }), + { REA_GHIDRA_PROJECT_PATH: join(root, "selected.gpr") }, + ); + 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 { + error: result.error, + projected: projectAnalysisError(result.error), + }; + }; + + it("projects a source project changed before its verified copy as artifact_changed", async () => { + const path = "/projects/selected"; + const reason = GHIDRA_PROJECT_PROFILE_MISMATCH; + const { error, projected } = await launchFailure( + new AnalysisArtifactChangedError("open_binary", path, reason), + ); + expect(projected).toMatchObject({ + code: "artifact_changed", + details: { + operation: "list_documents", + path, + reason, + boundary: "stable-artifact-read", + }, + }); + expect(projected.remediation.action).toBe( + projectAnalysisError( + new AnalysisArtifactChangedError("list_documents", path, reason), + ).remediation.action, + ); + expect(error.cause).toBeInstanceOf(GhidraSessionError); + }); + + it("keeps the generic start failure for other launch failures", async () => { + const { projected } = await launchFailure(new Error("spawn failed")); + 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 77bb0f1adf..a01afa9328 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -98,7 +98,7 @@ import { } from "./GhidraRiscvProfile.js"; import { ghidraPePdbLimitations } from "./GhidraPeProfile.js"; import { unverifiedGhidraBuildLimitation } from "./GhidraReleasePolicy.js"; -import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; +import { GhidraHeadlessLauncher, GhidraLaunchError } from "./GhidraLauncher.js"; import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js"; import type { NativeAotPeResult } from "../domain/native/nativeAotPe.js"; import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js"; @@ -1197,8 +1197,16 @@ const projectPrimarySessionError = ( ? {} : { partialObservation: failure.partialObservation }), }; + // Launch-time copies, such as the verified existing-project copy, report a + // changed source as the launcher's primary failure; read it through the + // launch wrapper so the refusal keeps its typed projection. + const launchChange = + failure.cause instanceof GhidraLaunchError && + failure.cause.primaryFailure instanceof AnalysisArtifactChangedError + ? failure.cause.primaryFailure + : undefined; const { cause: physicalCause, origin } = artifactFilePrimaryFacts( - failure.cause, + launchChange ?? failure.cause, ); // A client-side ceiling refusal travels as the cause; re-attribute it to this // operation so callers see a typed resource constraint rather than a generic diff --git a/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts b/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts index 1f1e0a0033..effb563d4a 100644 --- a/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts +++ b/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts @@ -15,6 +15,7 @@ import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourc import { copyExistingGhidraProject, fingerprintGhidraProject, + GHIDRA_PROJECT_PROFILE_MISMATCH, resolveGhidraProject, } from "../../../../src/ghidra/GhidraProjectSnapshot.js"; import { parseConfig } from "../../../../src/config/parseConfig.js"; @@ -85,7 +86,9 @@ it("rejects a project whose copied content differs from the committed analysis p }), ).rejects.toMatchObject({ _tag: "AnalysisArtifactChangedError", - cause: { name: "RegularFileChangedError" }, + operation: "open_binary", + path: source, + reason: GHIDRA_PROJECT_PROFILE_MISMATCH, }); });