fix(ghidra): keep a changed existing project's refusal typed through launch (#8273)

* fix(ghidra): keep a changed existing project's refusal typed through launch

When the selected REA_GHIDRA_PROJECT_PATH project changes after
open_binary commits its profile, the launcher's verified copy refuses it
with AnalysisArtifactChangedError. GhidraHeadlessLauncher wraps that in
GhidraLaunchError, and the session error's cause became the wrapper, so
the projection missed the existing artifact_changed branch and returned
generic execution_failure with rea doctor advice.

Read the launcher's primary failure when it is an
AnalysisArtifactChangedError, so the refusal reaches callers as
artifact_changed with the changed path and the existing reopen guidance.
The refusal stays fail-closed: no automatic reopen or fresh-import
fallback. Other launch failures keep their current projection.

Fixes #8217

* fix(ghidra): describe a project profile mismatch without a timing claim

When the owned copy of a selected Ghidra project no longer matches the
fingerprint committed by open_binary, copyExistingGhidraProject threw
RegularFileChangedError, so the artifact_changed reason said the input
"changed while being read". A digest mismatch only shows that the
project differs from its committed profile; the change may have happened
before the copy started.

Throw AnalysisArtifactChangedError directly with a reason that states
the mismatch and leaves the time of the change unestablished. Changes
observed while files are being read keep their existing reason.
This commit is contained in:
Machen John authored and GitHub committed 2026-11-04 19:33:29 +08:00
1 parent 1dd70f6c2b
commit 39172ca2e1
4 files changed
+105 -5

No files matched your search

+12 -1
View File
@@ -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 <Value>(
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,
);
}
});
};
+79 -1
View File
@@ -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,
+10 -2
View File
@@ -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
@@ -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,
});
});