mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 22:36:08 +00:00
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:
1 parent
1dd70f6c2b
commit
39172ca2e1
4 files changed
+105
-5
No files matched your search
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user