fix(ghidra): report selected project identity failures (#8379)
* fix(ghidra): report selected project identity failures * fix(ghidra): retain refusal until client shutdown
This commit is contained in:
1 parent
85be0f70a6
commit
2d16a40a49
11 files changed
+605
-77
No files matched your search
@@ -179,6 +179,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private List<FunctionEntry> procedureInventory;
|
||||
private List<InventoryItem> stringInventory;
|
||||
private DecompInterface decompiler;
|
||||
// A refused Program may only report its admission failure to the client.
|
||||
private String identityAdmissionFailure;
|
||||
/** Per-function native decompiler limit from the session; 0 leaves it unbounded. */
|
||||
private int decompileTimeoutSeconds;
|
||||
// DecompileProcess converts seconds to an int millisecond timer delay.
|
||||
@@ -224,14 +226,17 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
throw new IllegalStateException("Ghidra provider version does not match the session");
|
||||
}
|
||||
String importedSha256 = currentProgram.getExecutableSHA256();
|
||||
if (importedSha256 == null ||
|
||||
!constantTimeEquals(
|
||||
importedSha256.toLowerCase(Locale.ROOT),
|
||||
descriptor.targetSha256
|
||||
)) {
|
||||
throw new IllegalStateException(
|
||||
"Ghidra imported-byte digest does not match the admitted target"
|
||||
);
|
||||
if (importedSha256 == null || !importedSha256.matches("(?i)[0-9a-f]{64}")) {
|
||||
identityAdmissionFailure = "import_identity_unavailable";
|
||||
}
|
||||
else if (!constantTimeEquals(importedSha256.toLowerCase(Locale.ROOT),
|
||||
descriptor.targetSha256)) {
|
||||
identityAdmissionFailure = "import_identity_mismatch";
|
||||
}
|
||||
if (identityAdmissionFailure != null) {
|
||||
end(false);
|
||||
serve(descriptor);
|
||||
return;
|
||||
}
|
||||
admittedTargetSha256 = descriptor.targetSha256;
|
||||
// GhidraScript wraps run() in a transaction. End it before serving so
|
||||
@@ -405,6 +410,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
writeSuccess(writer, request.id, result);
|
||||
return;
|
||||
}
|
||||
if (identityAdmissionFailure != null) {
|
||||
// Keep the producer alive until the client has consumed the
|
||||
// refusal and closed or sent shutdown. Every other request
|
||||
// remains refused, including further startup attempts.
|
||||
writeFailure(writer, request.id, identityAdmissionFailure,
|
||||
"Ghidra imported identity admission failed");
|
||||
continue;
|
||||
}
|
||||
decompileTimeoutSeconds = request.decompileTimeoutSeconds() == null
|
||||
? descriptor.decompileTimeoutSeconds() : request.decompileTimeoutSeconds();
|
||||
writeSuccess(writer, request.id, handleRequest(request, descriptor));
|
||||
|
||||
@@ -1450,8 +1450,11 @@ the original database; REA reads selected files only for fingerprinting and copy
|
||||
This mode does not copy unrelated sibling projects, rerun repair scripts, or
|
||||
admit bridge database edits. Cleanup removes the owned copy.
|
||||
The bridge checks the program's recorded import SHA-256 against the admitted
|
||||
target digest; mismatches and project changes fail explicitly. Reopen the target
|
||||
to resolve a new profile after intentionally changing the source project.
|
||||
target digest. An unavailable or malformed recorded digest is reported as an
|
||||
unverified identity, distinct from a known mismatch; neither admits the program
|
||||
or triggers a fresh import. Select a matching target or explicitly configure a
|
||||
different project or program. Changes to the source project also fail admission;
|
||||
reopen the target to resolve a new profile after intentionally changing it.
|
||||
|
||||
The profile records the caller path, resolved project/name, program and content
|
||||
fingerprint. Original analysis settings and completeness remain **unknown**;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
requireMcpOutput,
|
||||
} from "./mcp-verifier-results.mjs";
|
||||
import { verifyGhidraBoundaries } from "./real-ghidra-boundaries.mjs";
|
||||
import { verifyGhidraProjectIdentity } from "./real-ghidra-project-identity.mjs";
|
||||
|
||||
const defaultEntrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url));
|
||||
const environment = () => ({
|
||||
@@ -317,6 +318,11 @@ async function verifyNativeValueAdapters(
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
const projectIdentity = await verifyGhidraProjectIdentity({
|
||||
target,
|
||||
entrypoint,
|
||||
env,
|
||||
});
|
||||
return {
|
||||
mocked: false,
|
||||
cli: true,
|
||||
@@ -324,6 +330,7 @@ async function verifyNativeValueAdapters(
|
||||
inline_evidence: true,
|
||||
direct_function_comparison: true,
|
||||
boundaries,
|
||||
project_identity: projectIdentity,
|
||||
nodes: cliGraph.total_nodes,
|
||||
edges: cliGraph.total_edges,
|
||||
decompilations: cliGraph.decompilations,
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
appendFile,
|
||||
copyFile,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { requireMcpToolError } from "./mcp-verifier-results.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
/** Verify selected-project identity refusal through real CLI and MCP adapters. */
|
||||
export async function verifyGhidraProjectIdentity({ target, entrypoint, env }) {
|
||||
if (process.platform === "win32")
|
||||
return {
|
||||
status: "not-applicable",
|
||||
reason: "Existing Ghidra projects are not admitted on Windows.",
|
||||
};
|
||||
assert.ok(env.GHIDRA_INSTALL_DIR, "Ghidra installation is required");
|
||||
const root = await mkdtemp(join(env.TMPDIR, "rea-project-identity-"));
|
||||
const targetPath = target.path;
|
||||
const different = join(root, "different-target");
|
||||
const headless = join(env.GHIDRA_INSTALL_DIR, "support", "analyzeHeadless");
|
||||
const fixtureEnv = {
|
||||
...env,
|
||||
HOME: root,
|
||||
XDG_CONFIG_HOME: join(root, "config"),
|
||||
XDG_CACHE_HOME: join(root, "cache"),
|
||||
XDG_DATA_HOME: join(root, "data"),
|
||||
JDK_JAVA_OPTIONS: `-Duser.home="${root}"`,
|
||||
};
|
||||
const program = basename(targetPath);
|
||||
const known = join(root, "known.gpr");
|
||||
const unknown = join(root, "unknown.gpr");
|
||||
const runtimeRootsBefore = (await readdir(env.TMPDIR))
|
||||
.filter((name) => name.startsWith("rea-ghidra-"))
|
||||
.sort();
|
||||
const sha = async (path) =>
|
||||
createHash("sha256")
|
||||
.update(await readFile(path))
|
||||
.digest("hex");
|
||||
try {
|
||||
await copyFile(targetPath, different);
|
||||
await appendFile(different, "\nREA_IDENTITY_VERIFIER\n");
|
||||
assert.notEqual(await sha(different), await sha(targetPath));
|
||||
await writeFile(
|
||||
join(root, "ClearImportDigest.java"),
|
||||
`import ghidra.app.script.GhidraScript;
|
||||
public class ClearImportDigest extends GhidraScript {
|
||||
@Override public void run() {
|
||||
currentProgram.setExecutableSHA256(null);
|
||||
println("REA_DIGEST_AFTER=" + currentProgram.getExecutableSHA256());
|
||||
}
|
||||
}\n`,
|
||||
);
|
||||
await exec(
|
||||
headless,
|
||||
[root, "known", "-import", targetPath, "-noanalysis"],
|
||||
{ env: fixtureEnv, timeout: 180000, maxBuffer: 8 * 1024 * 1024 },
|
||||
);
|
||||
const cleared = await exec(
|
||||
headless,
|
||||
[
|
||||
root,
|
||||
"unknown",
|
||||
"-import",
|
||||
targetPath,
|
||||
"-noanalysis",
|
||||
"-scriptPath",
|
||||
root,
|
||||
"-postScript",
|
||||
"ClearImportDigest.java",
|
||||
],
|
||||
{ env: fixtureEnv, timeout: 180000, maxBuffer: 8 * 1024 * 1024 },
|
||||
);
|
||||
assert.match(cleared.stdout, /REA_DIGEST_AFTER=unknown/u);
|
||||
const before = await projectState([known, unknown, targetPath, different]);
|
||||
|
||||
const knownEnv = {
|
||||
...env,
|
||||
REA_GHIDRA_PROJECT_PATH: known,
|
||||
REA_GHIDRA_PROGRAM_NAME: program,
|
||||
};
|
||||
const unknownEnv = {
|
||||
...env,
|
||||
REA_GHIDRA_PROJECT_PATH: unknown,
|
||||
REA_GHIDRA_PROGRAM_NAME: program,
|
||||
};
|
||||
const mismatch = await selectedProjectMcp({
|
||||
entrypoint,
|
||||
env: knownEnv,
|
||||
first: different,
|
||||
matching: targetPath,
|
||||
expected: "import_identity_mismatch",
|
||||
});
|
||||
const unavailable = await selectedProjectMcp({
|
||||
entrypoint,
|
||||
env: unknownEnv,
|
||||
first: targetPath,
|
||||
expected: "import_identity_unavailable",
|
||||
});
|
||||
for (const [label, selectedEnv, input, expected] of [
|
||||
["mismatch", knownEnv, different, "import_identity_mismatch"],
|
||||
["unavailable", unknownEnv, targetPath, "import_identity_unavailable"],
|
||||
]) {
|
||||
let failed;
|
||||
try {
|
||||
await exec(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"decompile",
|
||||
input,
|
||||
"0x0",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{ env: selectedEnv, timeout: 180000, maxBuffer: 8 * 1024 * 1024 },
|
||||
);
|
||||
} catch (cause) {
|
||||
assert.equal(cause.code, 1, `${label} CLI exit`);
|
||||
failed = JSON.parse(cause.stdout);
|
||||
}
|
||||
assert.ok(failed, `${label} CLI must refuse`);
|
||||
assert.equal(failed.code, "configuration_invalid");
|
||||
assert.equal(failed.details.diagnostics.remote_code, expected);
|
||||
}
|
||||
assert.deepEqual(
|
||||
await projectState([known, unknown, targetPath, different]),
|
||||
before,
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await readdir(env.TMPDIR))
|
||||
.filter((name) => name.startsWith("rea-ghidra-"))
|
||||
.sort(),
|
||||
runtimeRootsBefore,
|
||||
"Selected-project checks left a Ghidra runtime directory",
|
||||
);
|
||||
return {
|
||||
status: "verified",
|
||||
mismatch,
|
||||
unavailable,
|
||||
cli_refusals: true,
|
||||
source_files_unchanged: true,
|
||||
};
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function selectedProjectMcp({
|
||||
entrypoint,
|
||||
env,
|
||||
first,
|
||||
matching,
|
||||
expected,
|
||||
}) {
|
||||
const client = new Client({
|
||||
name: "rea-project-identity-verifier",
|
||||
version: "1",
|
||||
});
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const tools = new Map(
|
||||
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
|
||||
);
|
||||
const call = (name, arguments_) =>
|
||||
client.callTool(
|
||||
{ name, arguments: arguments_ },
|
||||
{ timeout: 180000, toolDefinition: tools.get(name) },
|
||||
);
|
||||
assert.notEqual(
|
||||
(await call("open_binary", { path: first, provider_id: "ghidra" }))
|
||||
.isError,
|
||||
true,
|
||||
);
|
||||
const rejected = requireMcpToolError(await call("list_documents", {}));
|
||||
assert.equal(
|
||||
rejected.code,
|
||||
"configuration_invalid",
|
||||
JSON.stringify({
|
||||
code: rejected.code,
|
||||
message: rejected.message,
|
||||
failure_kind: rejected.details?.diagnostics?.failure_kind,
|
||||
failure_message: rejected.details?.diagnostics?.failure_message,
|
||||
stdout: rejected.details?.diagnostics?.stdout?.text?.slice(-2500),
|
||||
stderr: rejected.details?.diagnostics?.stderr?.text?.slice(-1000),
|
||||
}),
|
||||
);
|
||||
assert.equal(rejected.details.diagnostics.remote_code, expected);
|
||||
if (matching !== undefined) {
|
||||
assert.notEqual(
|
||||
(await call("open_binary", { path: matching, provider_id: "ghidra" }))
|
||||
.isError,
|
||||
true,
|
||||
);
|
||||
assert.notEqual((await call("list_documents", {})).isError, true);
|
||||
}
|
||||
assert.notEqual((await call("close_binary", {})).isError, true);
|
||||
return {
|
||||
refused: expected,
|
||||
matching_target_reopened: matching !== undefined,
|
||||
};
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function projectState(paths) {
|
||||
const files = [];
|
||||
const walk = async (dir) => {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) await walk(path);
|
||||
else if (entry.isFile()) files.push(path);
|
||||
}
|
||||
};
|
||||
for (const path of paths) {
|
||||
files.push(path);
|
||||
if (path.endsWith(".gpr")) await walk(`${path.slice(0, -4)}.rep`);
|
||||
}
|
||||
files.sort();
|
||||
return Promise.all(
|
||||
files.map(async (path) => [
|
||||
path,
|
||||
createHash("sha256")
|
||||
.update(await readFile(path))
|
||||
.digest("hex"),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,8 @@ const GHIDRA_ADMISSION_CODES = [
|
||||
"prepared_options_exceed_bound",
|
||||
"output_unobserved",
|
||||
"handshake_identity",
|
||||
"import_identity_unavailable",
|
||||
"import_identity_mismatch",
|
||||
"response_shape",
|
||||
"response_json",
|
||||
"seed_path_differs",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type GhidraSessionFailureFactory,
|
||||
} from "./GhidraSessionError.js";
|
||||
import type { GhidraSessionInfo } from "./GhidraSessionValues.js";
|
||||
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
|
||||
|
||||
/** Inputs needed to complete the post-connection bridge handshake. */
|
||||
export interface GhidraStartupHandshakeOptions {
|
||||
@@ -42,6 +43,25 @@ export async function completeGhidraStartupHandshake(
|
||||
options.deadline.interruption !== undefined
|
||||
? interruptionFailure(options)
|
||||
: ping.error;
|
||||
if (
|
||||
failure.kind === "remote" &&
|
||||
(failure.remoteCode === "import_identity_unavailable" ||
|
||||
failure.remoteCode === "import_identity_mismatch")
|
||||
) {
|
||||
const admitted = options.failure(
|
||||
"admission",
|
||||
failure.message,
|
||||
new GhidraAdmissionError(failure.remoteCode, undefined, {
|
||||
cause: failure,
|
||||
}),
|
||||
{ remoteCode: failure.remoteCode },
|
||||
);
|
||||
return err(
|
||||
failure.cleanupFailure === undefined
|
||||
? admitted
|
||||
: admitted.withCleanupFailure(failure.cleanupFailure),
|
||||
);
|
||||
}
|
||||
return err(failure);
|
||||
}
|
||||
const parsed = options.parseSessionInfo(ping.value);
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
} from "./GhidraSessionValues.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { GhidraSessionError } from "./GhidraSessionError.js";
|
||||
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
|
||||
import { silentLogger } from "../logger.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
@@ -4173,6 +4174,46 @@ describe("Ghidra deadline guidance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["import_identity_unavailable", "did not report a valid imported SHA-256"],
|
||||
["import_identity_mismatch", "differs from the selected target"],
|
||||
] as const)(
|
||||
"reports fresh-import %s without blaming project configuration",
|
||||
async (code, message) => {
|
||||
const failure = new GhidraSessionError(
|
||||
"admission",
|
||||
"Identity admission failed",
|
||||
{},
|
||||
{
|
||||
cause: new GhidraAdmissionError(code),
|
||||
remoteCode: code,
|
||||
},
|
||||
);
|
||||
const ghidra = provider(installationHost(), () => ({
|
||||
start: async () => err(failure),
|
||||
callTool: async () => err(failure),
|
||||
close: async () => ok(null),
|
||||
}));
|
||||
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 profile");
|
||||
const result = await ghidra
|
||||
.createClient(target, profile.value.profile)
|
||||
.execute("list_documents", {});
|
||||
if (result.ok) throw new Error("Expected identity admission failure");
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected.code).toBe("execution_failure");
|
||||
expect(projected.message).toContain(message);
|
||||
expect(JSON.stringify(projected.details)).not.toContain(
|
||||
"REA_GHIDRA_PROJECT_PATH",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe("Ghidra startup deadline for a finished invocation", () => {
|
||||
const startupLimit = {
|
||||
setting: "ghidra.startup_timeout_ms",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.
|
||||
import { artifactFilePrimaryFacts } from "../artifacts/ArtifactFileOperation.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js";
|
||||
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
|
||||
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
@@ -1239,6 +1240,32 @@ const projectPrimarySessionError = (
|
||||
physicalCause.reason,
|
||||
context,
|
||||
);
|
||||
if (
|
||||
failure.kind === "admission" &&
|
||||
failure.cause instanceof GhidraAdmissionError &&
|
||||
(failure.cause.code === "import_identity_unavailable" ||
|
||||
failure.cause.code === "import_identity_mismatch")
|
||||
) {
|
||||
const unavailable = failure.cause.code === "import_identity_unavailable";
|
||||
const constraint = unavailable
|
||||
? "The Ghidra program did not report a valid imported SHA-256, so REA cannot verify its target identity"
|
||||
: "The Ghidra program's imported SHA-256 differs from the selected target's SHA-256";
|
||||
const remediationAction = unavailable
|
||||
? "Select a Ghidra program whose imported identity can be verified, or investigate the missing identity in Ghidra. Configure a different project or program when starting REA. REA did not fall back to a fresh import."
|
||||
: "Select a target matching the configured program and retry, or configure a matching project or program when starting REA. Resolve any reported cleanup before reopening. REA did not fall back to a fresh import.";
|
||||
if (limits.ghidraExistingProject !== undefined)
|
||||
return new ConfigurationError(constraint, {
|
||||
...context,
|
||||
settings: [{ setting: "REA_GHIDRA_PROJECT_PATH", constraint }],
|
||||
remediationAction,
|
||||
});
|
||||
return new ProviderAdapterError("ghidra", operation, {
|
||||
...context,
|
||||
userMessage: `${constraint}.`,
|
||||
remediationAction:
|
||||
"Inspect the retained Ghidra diagnostics and target identity before retrying the import.",
|
||||
});
|
||||
}
|
||||
// A well-formed address that lacks one exact source offset keeps the
|
||||
// bridge's reason and the caller's exact spelling, not schema advice.
|
||||
const fileOffset =
|
||||
|
||||
@@ -29,6 +29,9 @@ type FixtureMode =
|
||||
| "remote_error"
|
||||
| "decompile_timed_out"
|
||||
| "remote_startup_error"
|
||||
| "import_identity_unavailable"
|
||||
| "import_identity_mismatch"
|
||||
| "late_identity_failure"
|
||||
| "shutdown_error"
|
||||
| "invalid_shutdown_ack"
|
||||
| "hang_after_start"
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { ProgressUpdate } from "../../../../src/application/ProgressReporte
|
||||
import { err, ok } from "../../../../src/domain/result.js";
|
||||
import * as GhidraTransport from "../../../../src/ghidra/GhidraTransport.js";
|
||||
import { GhidraClient } from "../../../../src/ghidra/GhidraClient.js";
|
||||
import { GhidraAdmissionError } from "../../../../src/ghidra/GhidraAdmissionError.js";
|
||||
import { ProviderRunLineage } from "../../../../src/process/ProviderRunLineage.js";
|
||||
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
|
||||
import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js";
|
||||
@@ -1017,6 +1018,45 @@ it("retains launcher diagnostics when an input error rejects the startup handsha
|
||||
expect(result.error.diagnostics).not.toHaveProperty("diagnostic_scope");
|
||||
});
|
||||
|
||||
it.each(
|
||||
(
|
||||
["import_identity_unavailable", "import_identity_mismatch"] as const
|
||||
).flatMap((code) =>
|
||||
(HOST_TRANSPORT === "unix-socket"
|
||||
? ([HOST_TRANSPORT, "authenticated-loopback-tcp"] as const)
|
||||
: ([HOST_TRANSPORT] as const)
|
||||
).map((transport) => [code, transport] as const),
|
||||
),
|
||||
)(
|
||||
"retains structured %s from %s startup and cleans the failed bridge",
|
||||
async (code, transport) => {
|
||||
const launcher = new FixtureLauncher(code);
|
||||
const client = clientFor(launcher, { transport });
|
||||
const result = await client.start();
|
||||
if (result.ok) throw new Error("Expected identity admission failure");
|
||||
expect(result.error.kind).toBe("admission");
|
||||
expect(result.error.cause).toBeInstanceOf(GhidraAdmissionError);
|
||||
expect(result.error.cause).toMatchObject({ code });
|
||||
expect(client.isReady()).toBe(false);
|
||||
await expect(waitForExit(launcher.processes[0])).resolves.toBe(true);
|
||||
expect(launcher.requests).toEqual(["ping", "shutdown"]);
|
||||
await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not classify a later remote error as an identity admission failure", async () => {
|
||||
const client = clientFor(new FixtureLauncher("late_identity_failure"));
|
||||
expect((await client.start()).ok).toBe(true);
|
||||
const result = await client.callTool("list_strings", {});
|
||||
if (result.ok) throw new Error("Expected the fixture's later remote failure");
|
||||
expect(result.error.kind).toBe("remote");
|
||||
expect(result.error.remoteCode).toBe("import_identity_mismatch");
|
||||
expect(result.error.cause).not.toBeInstanceOf(GhidraAdmissionError);
|
||||
expect(client.isReady()).toBe(true);
|
||||
});
|
||||
|
||||
describe("GhidraClient", () => {
|
||||
it("completes an exact, fragmented post-analysis handshake", async () => {
|
||||
const launcher = new FixtureLauncher("fragmented");
|
||||
@@ -2073,10 +2113,73 @@ describe("GhidraClient shutdown diagnostics", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves failed startup and retained Ghidra cleanup capabilities until a verified retry", async () => {
|
||||
const fixture = new FixtureLauncher("wrong_identity");
|
||||
let cleanupAllowed = false;
|
||||
let cleanupAttempts = 0;
|
||||
it.each([
|
||||
["wrong_identity", "protocol"],
|
||||
["import_identity_unavailable", "admission"],
|
||||
] as const)(
|
||||
"preserves %s and retained Ghidra cleanup until a verified retry",
|
||||
async (mode, kind) => {
|
||||
const fixture = new FixtureLauncher(mode);
|
||||
let cleanupAllowed = false;
|
||||
let cleanupAttempts = 0;
|
||||
const launcher: GhidraLauncher = {
|
||||
launch: async (session) => {
|
||||
const launched = await fixture.launch(session);
|
||||
if (!launched.ok) return launched;
|
||||
return ok({
|
||||
...launched.value,
|
||||
cleanup: async () => {
|
||||
cleanupAttempts += 1;
|
||||
return cleanupAllowed
|
||||
? { cleaned: true as const, signaled: false }
|
||||
: {
|
||||
cleaned: false as const,
|
||||
reason: "fixture process cleanup unconfirmed",
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
const client = clientFor(launcher);
|
||||
onTestFinished(async () => {
|
||||
cleanupAllowed = true;
|
||||
await client.close();
|
||||
});
|
||||
const failed = await client.start();
|
||||
expect(failed).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind, cleanupFailure: { cleanupIncomplete: true } },
|
||||
});
|
||||
if (failed.ok) throw new Error("Expected failed startup");
|
||||
if (mode === "import_identity_unavailable")
|
||||
expect(failed.error.cause).toMatchObject({ code: mode });
|
||||
const runtime = fixture.runtimeRoots[0];
|
||||
if (runtime === undefined)
|
||||
throw new Error("Fixture runtime was not captured");
|
||||
await access(runtime);
|
||||
expect(cleanupAttempts).toBe(1);
|
||||
expect(await client.close()).toMatchObject({
|
||||
ok: false,
|
||||
error: { cleanupIncomplete: true },
|
||||
});
|
||||
cleanupAllowed = true;
|
||||
expect(await client.close()).toEqual(ok(null));
|
||||
expect(failed.error.cleanupFailure?.cleanupIncomplete).toBe(false);
|
||||
expect(cleanupAttempts).toBe(3);
|
||||
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it("retains the identity refusal when cancellation arrives during final startup cleanup", async () => {
|
||||
const fixture = new FixtureLauncher("import_identity_mismatch");
|
||||
let entered: (() => void) | undefined;
|
||||
let release: (() => void) | undefined;
|
||||
const cleanupStarted = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const launcher: GhidraLauncher = {
|
||||
launch: async (session) => {
|
||||
const launched = await fixture.launch(session);
|
||||
@@ -2084,39 +2187,40 @@ it("preserves failed startup and retained Ghidra cleanup capabilities until a ve
|
||||
return ok({
|
||||
...launched.value,
|
||||
cleanup: async () => {
|
||||
cleanupAttempts += 1;
|
||||
return cleanupAllowed
|
||||
? { cleaned: true as const, signaled: false }
|
||||
: {
|
||||
cleaned: false as const,
|
||||
reason: "fixture process cleanup unconfirmed",
|
||||
};
|
||||
entered?.();
|
||||
await gate;
|
||||
return { cleaned: true as const, signaled: false };
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
const client = clientFor(launcher);
|
||||
onTestFinished(async () => {
|
||||
cleanupAllowed = true;
|
||||
await client.close();
|
||||
});
|
||||
expect(await client.start()).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "protocol", cleanupFailure: { cleanupIncomplete: true } },
|
||||
});
|
||||
const runtime = fixture.runtimeRoots[0];
|
||||
if (runtime === undefined)
|
||||
throw new Error("Fixture runtime was not captured");
|
||||
await access(runtime);
|
||||
expect(cleanupAttempts).toBe(1);
|
||||
expect(await client.close()).toMatchObject({
|
||||
ok: false,
|
||||
error: { cleanupIncomplete: true },
|
||||
});
|
||||
cleanupAllowed = true;
|
||||
expect(await client.close()).toEqual(ok(null));
|
||||
expect(cleanupAttempts).toBe(3);
|
||||
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const client = clientFor(launcher, { startupTimeoutMs: 10_000 });
|
||||
const controller = new AbortController();
|
||||
const started = client.start(controller.signal);
|
||||
await withVerifierCleanup(async () => {
|
||||
await awaitAcknowledgement(
|
||||
cleanupStarted,
|
||||
started,
|
||||
"identity refusal cleanup",
|
||||
);
|
||||
controller.abort("cancel during final cleanup");
|
||||
release?.();
|
||||
const result = await started;
|
||||
if (result.ok) throw new Error("Expected the earlier identity refusal");
|
||||
expect(result.error.kind).toBe("admission");
|
||||
expect(result.error.cause).toMatchObject({
|
||||
code: "import_identity_mismatch",
|
||||
});
|
||||
expect(client.isReady()).toBe(false);
|
||||
await expect(access(fixture.runtimeRoots[0] ?? "")).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
}, [
|
||||
() => release?.(),
|
||||
async () => {
|
||||
await client.close();
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
@@ -2370,42 +2474,50 @@ it.each([null, "caller stopped", new Error("caller stopped")])(
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a handshake protocol error observed before the final startup caller cancels", async () => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
const logger = pino(
|
||||
{
|
||||
level: "warn",
|
||||
hooks: {
|
||||
logMethod(args, method) {
|
||||
const fields = args[0];
|
||||
if (
|
||||
typeof fields === "object" &&
|
||||
fields !== null &&
|
||||
"method" in fields &&
|
||||
fields.method === "ping" &&
|
||||
!cancelled
|
||||
) {
|
||||
cancelled = true;
|
||||
controller.abort(null);
|
||||
}
|
||||
method.apply(this, args);
|
||||
it.each([
|
||||
["malformed", "protocol"],
|
||||
["import_identity_unavailable", "admission"],
|
||||
] as const)(
|
||||
"preserves %s observed before the final startup caller cancels",
|
||||
async (mode, kind) => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
const logger = pino(
|
||||
{
|
||||
level: "warn",
|
||||
hooks: {
|
||||
logMethod(args, method) {
|
||||
const fields = args[0];
|
||||
if (
|
||||
typeof fields === "object" &&
|
||||
fields !== null &&
|
||||
"method" in fields &&
|
||||
fields.method === "ping" &&
|
||||
!cancelled
|
||||
) {
|
||||
cancelled = true;
|
||||
controller.abort(null);
|
||||
}
|
||||
method.apply(this, args);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ write: () => {} },
|
||||
);
|
||||
const launcher = new FixtureLauncher("malformed");
|
||||
const client = clientFor(launcher, { logger });
|
||||
const result = await client.start(controller.signal);
|
||||
expect(cancelled).toBe(true);
|
||||
if (result.ok) throw new Error("Expected handshake protocol failure");
|
||||
expect(result.error.kind).toBe("protocol");
|
||||
expect(await waitForExit(launcher.processes[0])).toBe(true);
|
||||
await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
{ write: () => {} },
|
||||
);
|
||||
const launcher = new FixtureLauncher(mode);
|
||||
const client = clientFor(launcher, { logger });
|
||||
const result = await client.start(controller.signal);
|
||||
expect(cancelled).toBe(true);
|
||||
if (result.ok) throw new Error("Expected handshake protocol failure");
|
||||
expect(result.error.kind).toBe(kind);
|
||||
if (mode === "import_identity_unavailable")
|
||||
expect(result.error.cause).toMatchObject({ code: mode });
|
||||
expect(await waitForExit(launcher.processes[0])).toBe(true);
|
||||
await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("redacts the transport credential when refreshing startup diagnostics", async () => {
|
||||
let token = "";
|
||||
|
||||
Vendored
+51
@@ -182,6 +182,40 @@ const handleRequest = (socket, server, request, state) => {
|
||||
handlePing(socket, request, state);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
["import_identity_unavailable", "import_identity_mismatch"].includes(
|
||||
state.mode,
|
||||
) &&
|
||||
request.method !== "shutdown"
|
||||
) {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: state.mode,
|
||||
message: "Ghidra imported identity admission failed",
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
state.mode === "late_identity_failure" &&
|
||||
request.method === "list_strings"
|
||||
) {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "import_identity_mismatch",
|
||||
message: "Unexpected later failure",
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
state.mode === "old_oom_then_malformed" &&
|
||||
request.method === "list_documents" &&
|
||||
@@ -341,6 +375,23 @@ const handleRequest = (socket, server, request, state) => {
|
||||
};
|
||||
|
||||
const handlePing = (socket, request, state) => {
|
||||
if (
|
||||
["import_identity_unavailable", "import_identity_mismatch"].includes(
|
||||
state.mode,
|
||||
)
|
||||
) {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: state.mode,
|
||||
message: "Ghidra project identity admission failed",
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (state.mode === "remote_startup_error") {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
|
||||
Reference in new issue
Block a user