feat(ghidra): checkpoint owned Programs across sessions

Add opt-in REA_GHIDRA_CHECKPOINT_DIR for owned Ghidra Program persistence through the shared CLI/MCP provider. Save completed initial analysis and metadata changes on normal close; verify and restore the last successful packed Program without auto-analysis.

Preserve generation identity, exclusive ownership, typed cancellation and cleanup retry. Refresh checkpoint-dependent profiles after retirement, including failed-switch restoration, while retaining caller selection provenance. Keep source executables and caller projects unchanged.

Validated with 720 focused tests (3 platform skips), 13 real Ghidra CLI/MCP scenarios, static/docs checks and CI. Review findings addressed. No version bump or release publication.

Refs #8165.
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-11-07 14:52:39 +08:00
1 parent 99db80a13e
commit c07ab4b994
32 files changed
+2711 -111

No files matched your search

+30
View File
@@ -187,6 +187,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
private static final int MAX_DECOMPILE_TIMEOUT_SECONDS = Integer.MAX_VALUE / 1000;
private final ReaGhidraExtensions analysisExtensions = new ReaGhidraExtensions();
private static AddressSpace sessionDefaultAddressSpace;
private Path checkpointDirectory;
private long checkpointModification;
@Override
public void run() throws Exception {
@@ -195,6 +197,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
throw new IllegalArgumentException("REA bridge requires one session descriptor");
}
Path descriptorPath = Path.of(arguments[0]);
checkpointDirectory = descriptorPath.toAbsolutePath().normalize().getParent();
SessionDescriptor descriptor = readDescriptor(descriptorPath);
decompileTimeoutSeconds = descriptor.decompileTimeoutSeconds();
// Windows retains a native immutable bearer descriptor lease until
@@ -247,6 +250,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
throw new IOException("Windows Ghidra P0 does not admit metadata recovery or database mutation.");
analysisExtensions.analyze(descriptor.analysisExtensions, currentProgram, monitor);
initializeDecompiler();
checkpointModification = currentProgram.getModificationNumber();
serve(descriptor);
}
finally {
@@ -451,6 +455,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (descriptor.existingProject && isMutatingOperation(request.method))
throw new RequestFailure("method_unavailable", "Existing Ghidra project sessions are read-only");
return switch (request.method) {
case "checkpoint_program" -> checkpointProgram(request.params, descriptor);
case "read_binary_inventory_summary" -> binaryInventorySummary(request.params);
case "inspect_native_load_image" -> inspectNativeLoadImage(request.params);
case "read_bytes" -> readMemoryBytes(request.params);
@@ -506,6 +511,31 @@ public final class ReaGhidraBridge extends HeadlessScript {
return method.equals("annotate_native_function") || method.equals("set_address_name") || method.equals("set_addresses_names");
}
/** Internal lifecycle request: the destination is inside this owned runtime. */
private JsonObject checkpointProgram(JsonObject params, SessionDescriptor descriptor) throws Exception {
requireKeys(params, Set.of("path", "force"));
if (descriptor.existingProject || !descriptor.transport.equals("unix-socket"))
throw new RequestFailure("method_unavailable", "This Program has no checkpoint authority");
Path output = Path.of(requireString(params, "path")).toAbsolutePath().normalize();
if (!output.getParent().equals(checkpointDirectory) ||
!output.getFileName().toString().matches("checkpoint-[a-f0-9-]{36}\\.gzf"))
throw new RequestFailure("invalid_request", "Checkpoint destination must belong to this runtime");
JsonElement forced = params.get("force");
if (!forced.isJsonPrimitive() || !forced.getAsJsonPrimitive().isBoolean())
throw new RequestFailure("invalid_request", "Checkpoint force must be a boolean");
boolean force = forced.getAsBoolean();
JsonObject result = new JsonObject();
boolean changed = currentProgram.getModificationNumber() != checkpointModification;
result.addProperty("saved", force || changed);
if (force || changed) {
if (Files.exists(output)) throw new RequestFailure("invalid_request", "Checkpoint destination already exists");
currentProgram.saveToPackedFile(output.toFile(), monitor);
checkpointModification = currentProgram.getModificationNumber();
result.addProperty("path", output.toString());
}
return result;
}
private JsonObject sessionInfo(SessionDescriptor descriptor) throws Exception {
boolean timedOut = analysisTimeoutOccurred();
JsonObject provider = new JsonObject();
+10 -1
View File
@@ -2,9 +2,18 @@
REA imports one target into an ephemeral Ghidra database. Linux/macOS support
read-only analysis and atomic function-name/entry-comment edits; Windows P0 is
read-only. GUI controls and persistent database mutation are unavailable.
read-only. GUI controls and writes to caller-selected source databases are unavailable.
Executable bytes remain unchanged.
An opt-in [owned Program checkpoint](installation.md#automatically-checkpoint-an-owned-ghidra-program)
retains analysis across Linux/macOS processes. It saves initial completed analysis
and normal-close metadata changes to `REA_GHIDRA_CHECKPOINT_DIR`; cancellation
and process failure recover only the last successfully saved state. The restored
packed Program's digest is reported with restored observations. Initial analysis
keeps one stable generation through its first save; later saved states bind their
content digest in profile identity. Restoration does not turn prior
database state into a fresh discovery. The default remains ephemeral.
Use [installation](installation.md#ghidra) for supported hosts, prerequisites,
configuration, startup deadlines, resource controls, and cleanup recovery.
[Linux Ghidra launches](installation.md#linux-owned-process-prerequisite) also
+61
View File
@@ -1427,6 +1427,67 @@ not needed for the make step, only for the Ghidra provider. If your toolchain
rejects the inherited `-mmacosx-version-min=10.6` flag, retry the make with
`ADDITIONAL_FLAGS="-w"`.
### Automatically checkpoint an owned Ghidra Program
On Linux/macOS, opt in with an absolute storage directory:
```bash
export REA_GHIDRA_CHECKPOINT_DIR=/absolute/rea-checkpoints
rea decompile /absolute/target/app --procedure entry --provider ghidra --json
```
Use the same setting in the MCP server environment. CLI and MCP share the
provider-owned implementation. The first successful analysis saves the complete
Program as a packed `.gzf`. Later processes restore its names, comments, types
and analysis into a private temporary project with `-noanalysis`. Normal close
saves metadata changes; unchanged queries do not rewrite the checkpoint.
The target executable is never executed by this workflow.
Storage is separated by target SHA-256 and analysis-profile identity, including
the supported Ghidra version. Identical executable bytes at another path can
reuse the Program. One live REA session holds the checkpoint's socket lease;
a competing session reports that owner and must wait until it closes. Only
REA-owned Programs are checkpointed. Caller-selected existing projects retain
their separate read-only workflow below.
The directory retains the current manifest and packed Program. Publication
writes and syncs a new packed file before atomically replacing the manifest;
superseded files are removed after publication. A failed save preserves the
previous checkpoint and the live session for retry. Correct the reported storage
problem, then call `close_binary` again. Incomplete cleanup retains its owner.
Corrupt, malformed or changed checkpoints fail explicitly instead of falling
back to a fresh import. Preserve that directory and repair it or explicitly
select a new directory to start fresh.
Recovery reaches **the last successfully saved checkpoint**. Cancellation,
process failure or a killed CLI can lose later edits; unfinished first-time
auto-analysis cannot resume midway. This does not restore a whole REA
investigation, and does not promise host-crash durability. The working project
remains ephemeral. Packed Program import, checksums, and saving consume disk and
time proportional to Program size; saving runs once after first analysis and
again only when the Program changes.
This initial mode supports stock native ELF, PE and Mach-O imports on POSIX.
Windows, special pinned-loader profiles, caller-selected `.gpr` projects,
language/analyzer overrides, analysis seeds and extra analysis extensions are
not admitted with this setting; unset it to use their existing workflows.
The checkpoint directory must be private to the current user.
REA analysis snapshots remain separate immutable query results. Initial analysis
has a stable generation bound to the target and analysis profile: publishing its
first checkpoint neither replaces a live MCP session nor invalidates its initial
snapshot. Later saved states bind their content digest, so an edited checkpoint
can require reopening without an older `--snapshot`. Snapshot import and save
after live metadata mutation remain unavailable as before. Complete Evidence
retains the checkpoint directory and generation in its analysis profile; restored
reads also report the packed file's path and verified digest. Returned caveats
distinguish restored database state from fresh analysis.
Run `npm run verify:ghidra:checkpoints` with your existing Ghidra/JDK installation
to verify fresh CLI/MCP processes, name/comment/type preservation, competing
sessions, corruption refusal and explicit recovery. Lifecycle fixtures cover
save failure/retry and cancellation during checkpoint finalization separately.
### Reuse an existing Ghidra project
For repeated investigations, select an expanded local project marker with
+1
View File
@@ -205,6 +205,7 @@
"verify:flutter": "npm run build:cached && node scripts/verify-flutter.mjs",
"verify:adb": "npm run build:cached && node scripts/verify-adb.mjs",
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ghidra:checkpoints": "npm run build:cached && node scripts/verify-real-ghidra-checkpoints.mjs",
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
"verify:ghidra:ppc": "npm run build:cached && node scripts/verify-real-ghidra-ppc.mjs",
+489
View File
@@ -0,0 +1,489 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFile,
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import {
requireMcpOperationResult,
requireMcpToolError,
} from "./lib/mcp-verifier-results.mjs";
import {
createVerifierRun,
completeVerifierRun,
assertVerifierCleanupComplete,
} from "./lib/verifier-run.mjs";
if (process.platform === "win32")
throw new Error(
"Owned Ghidra checkpoints currently support Linux/macOS only.",
);
const run = createVerifierRun(),
exec = promisify(execFile);
const root = await mkdtemp(join(tmpdir(), "rea-ghidra-checkpoints-"));
const runtime = join(root, "runtime"),
checkpointDirectory = join(root, "checkpoints");
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
const sourcePath = join(root, "fixture.c"),
target = join(root, "checkpoint-fixture");
const env = {
...process.env,
HOME: root,
XDG_CONFIG_HOME: join(root, "config"),
XDG_CACHE_HOME: join(root, "cache"),
XDG_DATA_HOME: join(root, "data"),
TMPDIR: runtime,
JDK_JAVA_OPTIONS: `-Duser.home=${root}`,
REA_ANALYSIS_PROVIDER: "ghidra",
REA_GHIDRA_CHECKPOINT_DIR: checkpointDirectory,
};
for (const name of [
"REA_GHIDRA_PROJECT_PATH",
"REA_GHIDRA_PROGRAM_NAME",
"REA_GHIDRA_SEED_FILE",
"REA_GHIDRA_NATIVEAOT_JAR",
"REA_GHIDRA_LANGUAGE_ID",
"REA_GHIDRA_COMPILER_SPEC_ID",
"REA_GHIDRA_DISABLED_ANALYZERS",
])
delete env[name];
const report = {
verifier: "ghidra-owned-checkpoints",
phases: [],
target: null,
};
const sha = async (path) =>
createHash("sha256")
.update(await readFile(path))
.digest("hex");
async function phase(name, operation) {
const start = performance.now();
try {
const facts = await operation();
report.phases.push({
name,
status: "verified",
elapsed_ms: Math.round(performance.now() - start),
...facts,
});
} catch (cause) {
report.phases.push({
name,
status: "failed",
elapsed_ms: Math.round(performance.now() - start),
});
throw cause;
}
}
async function cli(procedure, flags = [], path = target) {
const { stdout } = await exec(
process.execPath,
[
entrypoint,
"decompile",
path,
procedure,
"--provider",
"ghidra",
"--json",
...flags,
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
return JSON.parse(stdout);
}
async function refuseCli(procedure, flags, expected) {
let refusal;
try {
await cli(procedure, flags);
} catch (cause) {
assert.equal(cause.code, 1, cause.stderr);
refusal = JSON.parse(cause.stdout);
}
assert.ok(refusal, "CLI unexpectedly accepted unavailable checkpoint state");
assert.equal(refusal.code, expected, JSON.stringify(refusal));
return refusal;
}
async function connect(environment = {}) {
const client = new Client({
name: "rea-ghidra-checkpoint-verifier",
version: "1",
});
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: { ...env, ...environment },
stderr: "pipe",
});
transport.stderr?.on("data", () => {});
try {
await client.connect(transport);
} catch (cause) {
await client.close();
await transport.close();
throw cause;
}
const raw = (name, args = {}) =>
client.callTool({ name, arguments: args }, { timeout: 240000 });
return {
raw,
call: async (name, args = {}) =>
requireMcpOperationResult(await raw(name, args), name),
close: async () => {
await client.close();
await transport.close();
},
};
}
let manifestPath;
const manifest = async () => JSON.parse(await readFile(manifestPath, "utf8"));
try {
await mkdir(runtime);
await writeFile(
sourcePath,
`typedef struct CheckpointRecord { int value; int flag; } CheckpointRecord;
__attribute__((noinline)) int rea_checkpoint_leaf(CheckpointRecord *record) { return record->value * 7 + record->flag; }
int main(void) { CheckpointRecord record = { 3, 11 }; return rea_checkpoint_leaf(&record); }
`,
);
await exec("cc", [
"-O0",
"-g",
"-gdwarf-4",
"-fno-inline",
sourcePath,
"-o",
target,
]);
const targetDigest = await sha(target);
report.target = {
sha256: targetDigest,
source: "self-built C struct/function fixture; never executed",
host: process.platform,
architecture: process.arch,
};
let original, initialManifest;
const snapshotPath = join(root, "analysis-snapshot.json");
await phase(
"a cold MCP session keeps its live Program across initial save and repeated open",
async () => {
const cold = await connect({
REA_GHIDRA_CHECKPOINT_DIR: join(root, "cold-session-checkpoints"),
});
try {
await cold.call("open_binary", { path: target, provider_id: "ghidra" });
await cold.call("procedure_pseudo_code", {
procedure: "rea_checkpoint_leaf",
});
const id = (await cold.call("binary_session")).analysis_run.run_id;
await cold.call("annotate_native_function", {
procedure: "rea_checkpoint_leaf",
comment: "cold-session unsaved comment",
});
await cold.call("open_binary", { path: target, provider_id: "ghidra" });
assert.equal(
(await cold.call("binary_session")).analysis_run.run_id,
id,
);
const current = await cold.call("procedure_pseudo_code", {
procedure: "rea_checkpoint_leaf",
});
assert.match(JSON.stringify(current), /cold-session unsaved comment/);
await cold.call("close_binary");
return { same_live_run: true, unsaved_metadata_retained: true };
} finally {
await cold.close();
}
},
);
await phase("first analysis creates a checkpoint", async () => {
original = await cli("rea_checkpoint_leaf", ["--snapshot", snapshotPath]);
assert.match(JSON.stringify(original.normalized_result), /value/);
const keys = await readdir(checkpointDirectory);
assert.equal(keys.length, 1);
manifestPath = join(checkpointDirectory, keys[0], "current.json");
initialManifest = await manifest();
assert.equal(initialManifest.target_sha256, targetDigest);
assert.deepEqual(
original.analysis_profile.parameters.owned_checkpoint.state,
{ kind: "initial-analysis" },
);
return {
provider_version: original.provider.version,
response_bytes: Buffer.byteLength(JSON.stringify(original)),
};
});
await phase(
"the first checkpoint publication preserves initial immutable snapshot identity",
async () => {
const cached = await cli("rea_checkpoint_leaf", [
"--snapshot",
snapshotPath,
]);
assert.deepEqual(cached, original);
assert.deepEqual(await manifest(), initialManifest);
return {};
},
);
await phase(
"fresh CLI restores the Program and does not rewrite it",
async () => {
const restored = await cli("rea_checkpoint_leaf");
assert.deepEqual(restored.normalized_result, original.normalized_result);
assert.equal(
restored.analysis_profile.digest,
original.analysis_profile.digest,
);
assert.ok(
restored.limitations.some((value) =>
value.includes(initialManifest.source.sha256),
),
);
assert.deepEqual(await manifest(), initialManifest);
return {
checkpoint_sha256: initialManifest.source.sha256,
response_bytes: Buffer.byteLength(JSON.stringify(restored)),
};
},
);
await phase(
"another path with identical target bytes reuses the same Program",
async () => {
const alias = join(root, "renamed-target");
await copyFile(target, alias);
const restored = await cli("rea_checkpoint_leaf", [], alias);
assert.deepEqual(restored.normalized_result, original.normalized_result);
assert.equal(restored.caller_input_path, alias);
assert.deepEqual(await manifest(), initialManifest);
return {};
},
);
await phase(
"MCP preserves a live run, refuses a concurrent writer, and saves edits",
async () => {
const active = await connect();
try {
await active.call("open_binary", {
path: target,
provider_id: "ghidra",
});
await active.call("procedure_pseudo_code", {
procedure: "rea_checkpoint_leaf",
});
const id = (await active.call("binary_session")).analysis_run.run_id;
const other = await connect();
try {
await other.call("open_binary", {
path: target,
provider_id: "ghidra",
});
const error = requireMcpToolError(await other.raw("list_documents"));
assert.equal(error.code, "configuration_invalid");
assert.match(error.message, /another REA session/);
await other.call("close_binary");
} finally {
await other.close();
}
await active.call("open_binary", {
path: target,
provider_id: "ghidra",
});
assert.equal(
(await active.call("binary_session")).analysis_run.run_id,
id,
);
await active.call("annotate_native_function", {
procedure: "rea_checkpoint_leaf",
name: "rea_checkpoint_retained",
comment: "REA checkpoint first comment",
});
await active.call("close_binary");
assert.notDeepEqual(await manifest(), initialManifest);
return { same_live_run: true, concurrent_owner_refused: true };
} finally {
await active.close();
}
},
);
await phase("new MCP restores edits and remains editable", async () => {
const resumed = await connect();
try {
await resumed.call("open_binary", {
path: target,
provider_id: "ghidra",
});
const analysis = await resumed.call("analyze_function", {
procedure: "rea_checkpoint_retained",
detail: "complete",
});
assert.equal(analysis.procedure.name, "rea_checkpoint_retained");
assert.match(JSON.stringify(analysis), /REA checkpoint first comment/);
assert.match(analysis.pseudocode, /value/);
await resumed.call("annotate_native_function", {
procedure: "rea_checkpoint_retained",
comment: "REA checkpoint second comment",
});
await resumed.call("close_binary");
return { structure_field_preserved: true };
} finally {
await resumed.close();
}
});
await phase("CLI sees the second MCP edit", async () => {
const result = await cli("rea_checkpoint_retained");
assert.match(
JSON.stringify(result.normalized_result),
/REA checkpoint second comment/,
);
return {};
});
await phase(
"an older immutable REA snapshot refuses the changed checkpoint",
async () => {
const refusal = await refuseCli(
"rea_checkpoint_leaf",
["--snapshot", snapshotPath],
"evidence_integrity_mismatch",
);
assert.ok(refusal.details.binding_mismatch.profile_digest);
return {};
},
);
await phase(
"an edited live Program switches to identical bytes at another path",
async () => {
const active = await connect();
const alias = join(root, "live-alias");
await copyFile(target, alias);
try {
await active.call("open_binary", {
path: target,
provider_id: "ghidra",
});
await active.call("annotate_native_function", {
procedure: "rea_checkpoint_retained",
comment: "REA checkpoint second comment; saved during alias switch",
});
await active.call("open_binary", {
path: alias,
provider_id: "ghidra",
});
const result = await active.call("procedure_pseudo_code", {
procedure: "rea_checkpoint_retained",
});
assert.match(JSON.stringify(result), /saved during alias switch/);
await active.call("close_binary");
return { alias_switch_preserved_edits: true };
} finally {
await active.close();
}
},
);
const current = await manifest(),
bytes = await readFile(manifestPath);
await phase(
"target/profile mismatch is refused without replacing the saved state",
async () => {
try {
await writeFile(
manifestPath,
JSON.stringify({ ...current, target_sha256: "0".repeat(64) }),
);
await refuseCli("rea_checkpoint_retained", [], "configuration_invalid");
} finally {
await writeFile(manifestPath, bytes);
}
return {};
},
);
await phase(
"malformed manifest is refused without a fresh import",
async () => {
try {
await writeFile(manifestPath, "{");
await refuseCli("rea_checkpoint_retained", [], "configuration_invalid");
} finally {
await writeFile(manifestPath, bytes);
}
return {};
},
);
await phase(
"corrupt packed data is refused and explicit restoration recovers",
async () => {
const packed = join(
checkpointDirectory,
(await readdir(checkpointDirectory))[0],
current.source.file,
),
backup = join(root, "good.gzf");
await copyFile(packed, backup);
try {
await writeFile(packed, "corrupted packed data");
await refuseCli("rea_checkpoint_retained", [], "configuration_invalid");
} finally {
await copyFile(backup, packed);
}
const result = await cli("rea_checkpoint_retained");
assert.match(
JSON.stringify(result.normalized_result),
/REA checkpoint second comment/,
);
return {};
},
);
assert.equal(await sha(target), targetDigest);
assert.deepEqual(await readdir(runtime), []);
const checkpointFiles = await readdir(
join(checkpointDirectory, (await readdir(checkpointDirectory))[0]),
);
assert.equal(
checkpointFiles.length,
2,
"Only the current manifest and packed Program should remain",
);
report.status = "verified";
} catch (cause) {
report.status = "failed";
report.error = cause instanceof Error ? cause.stack : String(cause);
if (typeof cause?.stdout === "string")
report.failure_stdout = cause.stdout.slice(-12000);
if (typeof cause?.stderr === "string")
report.failure_stderr = cause.stderr.slice(-4000);
process.exitCode = 1;
} finally {
report.run = await completeVerifierRun(run);
let cleanupVerified = false;
try {
assertVerifierCleanupComplete(report.run.process_lineage);
cleanupVerified = true;
} catch (cause) {
report.cleanup_error = String(cause);
report.retained_workspace = root;
process.exitCode = 1;
}
if (cleanupVerified) {
try {
await rm(root, { recursive: true, force: true });
} catch (cause) {
report.cleanup_error = String(cause);
report.retained_workspace = root;
process.exitCode = 1;
}
}
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
}
+2
View File
@@ -249,6 +249,8 @@ export interface ProviderIdentity {
/** Typed analysis profile resolved before provider startup; null means unresolved. */
export interface AnalysisProfileResolution {
readonly profile: AnalysisProfileCommitment | null;
/** Closing a client can publish state that invalidates a previewed profile. */
readonly refreshAfterRetirement?: true;
/** Keep provider-private facts bound to this selection without Evidence serialization. */
readonly createClient?: AnalysisProviderBoundClientFactory;
}
@@ -63,6 +63,7 @@ const SELECTED_REGISTRATION_SETTINGS = [
"REA_GHIDRA_DISABLED_ANALYZERS",
"REA_GHIDRA_SEED_FILE",
"REA_GHIDRA_PROJECT_PATH",
"REA_GHIDRA_CHECKPOINT_DIR",
"REA_GHIDRA_PROGRAM_NAME",
"REA_GHIDRA_NATIVEAOT_JAR",
"REA_HOPPER_CALLER_SCAN_BUDGET_MS",
@@ -24,6 +24,7 @@ export interface AnalysisProviderCandidateEvaluation {
readonly candidate: AnalysisProviderCandidate;
readonly status: AnalysisProviderCandidateStatus;
readonly profile?: AnalysisProfileCommitment;
readonly refreshAfterRetirement?: true;
readonly createClient?: AnalysisProviderBoundClientFactory;
readonly profileError?: AnalysisError;
}
@@ -118,6 +119,9 @@ export const evaluateAnalysisProviderCandidate = async (
candidate,
status,
profile,
...(resolved.value.refreshAfterRetirement === undefined
? {}
: { refreshAfterRetirement: resolved.value.refreshAfterRetirement }),
...(resolved.value.createClient === undefined
? {}
: { createClient: resolved.value.createClient }),
@@ -80,6 +80,7 @@ export interface AnalysisProviderBinding {
readonly identity: ProviderIdentity & { readonly version: string };
readonly selectionSource: "request" | "environment" | "auto-single-candidate";
readonly profile: AnalysisProfileCommitment;
readonly refreshAfterRetirement?: true;
readonly createClient?: AnalysisProviderBoundClientFactory;
/** The provider's capabilities, attributed to its resolved identity. */
readonly capabilities: readonly CapabilityDescriptor[];
@@ -831,6 +832,9 @@ const selectedResult = (
identity: structuredClone(identity),
selectionSource: source,
profile: structuredClone(evaluated.profile),
...(evaluated.refreshAfterRetirement === undefined
? {}
: { refreshAfterRetirement: evaluated.refreshAfterRetirement }),
...(evaluated.createClient === undefined
? {}
: { createClient: evaluated.createClient }),
+35 -4
View File
@@ -600,8 +600,9 @@ export class BinarySession
);
const resolved = await resolve();
if (!resolved.ok) return resolved;
const { target, route, sameTarget } = resolved.value;
const { profile } = route;
const { target, sameTarget } = resolved.value;
let { route } = resolved.value;
let { profile } = route;
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_binary", {
@@ -671,6 +672,29 @@ export class BinarySession
}),
),
);
if (previous !== undefined) {
const refreshed = await this.#providerRouter.refreshAfterRetirement(
route,
target,
options.signal,
);
const admitted = refreshed.ok
? validateResolvedSessionTarget({
target,
route: refreshed.value,
current: previous,
options,
stagedSnapshotMismatch: (target, profile) =>
this.snapshotMismatch(target, profile),
})
: refreshed;
if (!admitted.ok)
return err(
await this.#restoreAfterFailedOpen(previous, admitted.error),
);
route = admitted.value.route;
profile = route.profile;
}
const runId = randomUUID();
const client = route.createClient(target, {
runId,
@@ -1648,15 +1672,22 @@ export class BinarySession
previous: SessionBinding | undefined,
): Promise<Result<null, AnalysisError>> {
if (previous === undefined) return ok(null);
const refreshed = await this.#providerRouter.refreshAfterRetirement(
previous.route,
previous.target,
);
if (!refreshed.ok) return refreshed;
const route = refreshed.value;
const runId = randomUUID();
const client = previous.route.createClient(previous.target, {
const client = route.createClient(previous.target, {
runId,
});
const run: SessionRun = { client, route: previous.route, runId };
const run: SessionRun = { client, route, runId };
const started = await client.execute("health", {});
if (started.ok) {
this.#active = {
...previous,
profile: route.profile,
...run,
nativeSourceAdmitted: false,
};
@@ -1,4 +1,9 @@
import { expect, it } from "vitest";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { elf } from "../../domain/binaryTarget.fixture.js";
import { createTestBinarySession } from "../../../tests/fixtures/binarySession.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
@@ -16,6 +21,81 @@ import {
type SessionProviderRoute,
} from "./SessionProviderRouter.js";
it.each([false, true])(
"refreshes state published by close on alias switch (incoming fails: %s)",
async (failIncoming) => {
const directory = await createTestTempDirectory("rea-published-profile-");
const first = join(directory, "first.elf"),
alias = join(directory, "alias.elf");
await writeFile(first, elf(2, 1, 62));
await writeFile(alias, elf(2, 1, 62));
const identity = {
id: "fixture",
name: "Fixture analysis provider",
version: "1",
};
let generation = 0,
dirty = false;
const starts: number[] = [];
const failure = new AnalysisOutputError(
"health",
"Incoming target refused",
);
const session = createTestBinarySession(
(_target, profile) => ({
execute: async () => {
const selected = profile?.parameters.generation;
starts.push(Number(selected));
if (selected !== generation)
return err(
new AnalysisOutputError("health", "Stale published profile"),
);
return ok(createAnalysisExecution(null, identity));
},
close: async () => {
if (dirty) {
generation++;
dirty = false;
}
return ok(null);
},
}),
{
resolveAnalysisProfile: async (target) =>
failIncoming && generation > 0 && target.path === alias
? err(failure)
: ok({
profile: createAnalysisProfile(identity, { generation }),
refreshAfterRetirement: true,
}),
},
);
try {
expect((await session.open(first)).ok).toBe(true);
expect((await session.execute("list_strings", {})).ok).toBe(true);
dirty = true;
const switched = await session.open(alias);
if (failIncoming) {
expect(switched.ok).toBe(false);
expect(session.activeTarget()?.path).toBe(first);
} else {
expect(switched.ok).toBe(true);
expect(session.activeTarget()?.path).toBe(alias);
}
expect(session.analysisProfile()?.parameters.generation).toBe(1);
expect(await session.status()).toMatchObject({
analysis_provider_binding: {
selection_source: "auto-single-candidate",
},
});
expect((await session.execute("list_strings", {})).ok).toBe(true);
expect(starts).toEqual([0, 1]);
} finally {
expect((await session.close()).ok).toBe(true);
}
},
);
it("reports the previous selection after a failed switch restores its provider", async () => {
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
@@ -25,6 +25,7 @@ export interface SessionProviderRoute {
readonly identity: ProviderIdentity;
readonly capabilities: ReadonlyMap<string, CapabilityDescriptor>;
readonly profile: AnalysisProfileCommitment | null;
readonly refreshAfterRetirement?: true;
readonly binding: AnalysisProviderBinding | null;
readonly selection: AnalysisProviderSelection | undefined;
createClient(
@@ -97,6 +98,37 @@ export class SessionProviderRouter {
: selected;
}
/** Refresh only provider selections whose state can be published by close. */
async refreshAfterRetirement(
route: SessionProviderRoute,
target: BinaryTarget,
signal?: AbortSignal,
): Promise<Result<SessionProviderRoute, AnalysisError>> {
if (route.refreshAfterRetirement !== true || route.binding === null)
return ok(route);
const refreshed = await this.registry.select(
target,
route.binding.identity.id,
signal === undefined ? {} : { signal },
);
if (!refreshed.ok) return refreshed;
return ok(
selectableRoute(this.auxiliaryProviders, {
...refreshed.value,
requestedProviderId:
route.selection?.requestedProviderId ??
refreshed.value.requestedProviderId,
binding:
refreshed.value.binding === null
? null
: {
...refreshed.value.binding,
selectionSource: route.binding.selectionSource,
},
}),
);
}
/** Candidate status authoritative for the active or target-free route. */
async candidateStatuses(
route: SessionProviderRoute,
@@ -156,6 +188,9 @@ const selectableRoute = (
identity,
capabilities,
profile: binding?.profile ?? null,
...(binding?.refreshAfterRetirement === undefined
? {}
: { refreshAfterRetirement: binding.refreshAfterRetirement }),
binding,
selection,
createClient: (target, context) =>
+36
View File
@@ -11,6 +11,42 @@ import {
} from "./config/environment.js";
import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
it("keeps the caller's opt-in Ghidra checkpoint directory and leaves it disabled by default", () => {
const selected = parseConfig({
REA_GHIDRA_CHECKPOINT_DIR: "/tmp/checkpoints with spaces",
});
expect(selected.ok && selected.value.ghidraCheckpointDirectory).toBe(
"/tmp/checkpoints with spaces",
);
const defaults = parseConfig({});
expect(
defaults.ok && defaults.value.ghidraCheckpointDirectory,
).toBeUndefined();
expect(parseConfig({ REA_GHIDRA_CHECKPOINT_DIR: "relative" }).ok).toBe(false);
});
it.each([
["REA_GHIDRA_PROJECT_PATH", "/tmp/selected.gpr"],
["REA_GHIDRA_SEED_FILE", "/tmp/seeds.json"],
["REA_GHIDRA_LANGUAGE_ID", "x86:LE:64:default"],
["REA_GHIDRA_NATIVEAOT_JAR", "/tmp/extension.jar"],
["REA_GHIDRA_DISABLED_ANALYZERS", "Stack"],
])(
"refuses %s with checkpoints rather than silently dropping the selected input",
(setting, value) => {
const result = parseConfig({
REA_GHIDRA_CHECKPOINT_DIR: "/tmp/checkpoints",
[setting]: value,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("conflicting inputs accepted");
expect(result.error.message).toContain("checkpoints");
expect(result.error).toMatchObject({
settings: [expect.objectContaining({ setting })],
});
},
);
it("reports a configuration failure for a NUL in Hopper loader arguments", () => {
const result = parseConfig({
HOPPER_LOADER_ARGS_JSON: JSON.stringify(["--option", "value\0suffix"]),
+29
View File
@@ -123,6 +123,14 @@ const environmentSchema = z.object({
),
REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS: ghidraDecompileTimeoutSchema.optional(),
REA_GHIDRA_PROJECT_PATH: ghidraProjectPathSchema.optional(),
REA_GHIDRA_CHECKPOINT_DIR: z
.string()
.min(1)
.refine(
(value) => value.startsWith("/") && !value.includes("\0"),
"must be an absolute POSIX directory path",
)
.optional(),
REA_GHIDRA_PROGRAM_NAME: ghidraProgramNameSchema.optional(),
REA_GHIDRA_LANGUAGE_ID: ghidraLanguageIdSchema.optional(),
REA_GHIDRA_COMPILER_SPEC_ID: ghidraCompilerSpecIdSchema.optional(),
@@ -243,6 +251,27 @@ export const parseEnvironment = (
const parsed = parseEnvironmentValues(environment, unresolvedSettings);
if (!parsed.ok) return parsed;
const env = parsed.value;
if (env.REA_GHIDRA_CHECKPOINT_DIR !== undefined) {
const conflicts = [
"REA_GHIDRA_PROJECT_PATH",
"REA_GHIDRA_SEED_FILE",
"REA_GHIDRA_NATIVEAOT_JAR",
"REA_GHIDRA_LANGUAGE_ID",
"REA_GHIDRA_DISABLED_ANALYZERS",
].filter((key) => Reflect.get(env, key) !== undefined);
if (conflicts.length > 0)
return err(
new ConfigurationError(
"Ghidra checkpoints currently support stock fresh-import analysis only",
{
settings: conflicts.map((setting) => ({
setting,
constraint: "cannot be combined with REA_GHIDRA_CHECKPOINT_DIR",
})),
},
),
);
}
const project = env.REA_GHIDRA_PROJECT_PATH;
const incompatible =
project === undefined
+3
View File
@@ -137,6 +137,9 @@ export const parseConfig = (
},
}),
ghidraInstallDir: env.GHIDRA_INSTALL_DIR,
...(env.REA_GHIDRA_CHECKPOINT_DIR === undefined
? {}
: { ghidraCheckpointDirectory: env.REA_GHIDRA_CHECKPOINT_DIR }),
ghidraJavaHome: env.JAVA_HOME,
ghidraStartupTimeoutMs: env.REA_GHIDRA_STARTUP_TIMEOUT_MS,
...(env.REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS === undefined
+2
View File
@@ -31,6 +31,8 @@ export interface AppConfig {
readonly ghidraDisabledAnalyzers?: readonly string[];
readonly ghidraSeedFile?: string;
readonly ghidraExistingProject?: GhidraProjectSelection;
/** Opt-in storage for checkpoints of REA-owned Ghidra Programs. */
readonly ghidraCheckpointDirectory?: string;
readonly ghidraNativeAotJar?: string;
readonly ilspyCmdPath: string | undefined;
readonly hopperLauncherPath: string;
+514
View File
@@ -0,0 +1,514 @@
import { afterEach, expect, it, vi } from "vitest";
import {
chmod,
access,
mkdtemp,
mkdir,
readFile,
writeFile,
rm,
rename,
symlink,
type FileHandle,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { createAnalysisProfile } from "../domain/analysisProfile.js";
import { acquireOwnedLease } from "../process/OwnedLease.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
GhidraCheckpoint,
resolveGhidraCheckpoint,
} from "./GhidraCheckpoint.js";
const controls = vi.hoisted(() => ({
file: undefined as ((handle: FileHandle, path: string) => void) | undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
rename: vi.fn(actual.rename),
rm: vi.fn(actual.rm),
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
controls.file?.(handle, String(args[0]));
return handle;
},
};
});
vi.mock("../process/OwnedLease.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../process/OwnedLease.js")>();
return { ...actual, acquireOwnedLease: vi.fn(actual.acquireOwnedLease) };
});
const roots: string[] = [];
const owners: GhidraCheckpoint[] = [];
afterEach(async () => {
controls.file = undefined;
vi.restoreAllMocks();
const leases = await vi.importActual<
typeof import("../process/OwnedLease.js")
>("../process/OwnedLease.js");
vi.mocked(acquireOwnedLease)
.mockReset()
.mockImplementation(leases.acquireOwnedLease);
const actual =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(rm).mockReset().mockImplementation(actual.rm);
vi.mocked(rename).mockReset().mockImplementation(actual.rename);
for (const owner of owners.splice(0)) await owner.close();
for (const root of roots.splice(0))
await rm(root, { recursive: true, force: true });
});
it.skipIf(process.platform === "win32")(
"retains lease identity after release fails and releases it on retry",
async () => {
const f = await fixture();
const actual = await vi.importActual<
typeof import("../process/OwnedLease.js")
>("../process/OwnedLease.js");
vi.mocked(acquireOwnedLease).mockImplementationOnce(async (input) => {
const result = await actual.acquireOwnedLease(input);
if (!result.acquired) return result;
let failed = false;
return {
acquired: true,
lease: {
...result.lease,
release: async () => {
if (!failed) {
failed = true;
throw new Error("controlled lease release failure");
}
await result.lease.release();
},
},
};
});
await f.owner.acquire("original");
const retained = [...f.owner.cleanupResources];
expect(retained).toHaveLength(1);
await expect(f.owner.close()).rejects.toThrow(
"controlled lease release failure",
);
expect(f.owner.cleanupResources).toEqual(retained);
expect(f.owner.hasResources).toBe(true);
await f.owner.close();
expect(f.owner.cleanupResources).toEqual([]);
const peer = new GhidraCheckpoint(f.selection, f.resources);
owners.push(peer);
expect(await peer.acquire("replacement")).toBeUndefined();
},
);
const profile = createAnalysisProfile(
{ id: "ghidra", name: "Ghidra", version: "12.1.4" },
{ import_mode: "test" },
);
const target = "1".repeat(64);
async function fixture() {
const root = await mkdtemp(join(tmpdir(), "rea-checkpoint-test-"));
roots.push(root);
const resources = new ArtifactResourceScope();
const selection = await resolveGhidraCheckpoint(
root,
target,
profile,
resources,
);
const owner = new GhidraCheckpoint(selection, resources);
owners.push(owner);
const packed = join(root, "fixture.gzf");
await writeFile(packed, "controlled packed bytes");
return { root, resources, selection, owner, packed };
}
it.skipIf(process.platform === "win32").each(
["manifest", "packed"].flatMap((stage) => [
{ stage, cancel: true, closeFails: false },
{ stage, cancel: true, closeFails: true },
{ stage, cancel: false, closeFails: true },
]),
)(
"preserves $stage cancellation=$cancel and descriptor cleanup=$closeFails",
async ({ stage, cancel, closeFails }) => {
const f = await fixture();
await f.owner.acquire("writer");
await f.owner.publish(f.packed);
await f.owner.close();
const controller = new AbortController();
const reason = { caller: "stopped hashing" };
controls.file = (handle, path) => {
if (!path.endsWith(stage === "manifest" ? "current.json" : ".gzf"))
return;
const read = handle.read.bind(handle);
vi.spyOn(handle, "read").mockImplementation(
async (...args: Parameters<typeof handle.read>) => {
const result = await read(...args);
if (cancel) controller.abort(reason);
return result;
},
);
if (closeFails)
vi.spyOn(handle, "close").mockRejectedValueOnce(
new Error("controlled checkpoint close failure"),
);
};
try {
const error = await resolveGhidraCheckpoint(
f.root,
target,
profile,
f.resources,
controller.signal,
).catch((cause: unknown) => cause);
if (cancel) {
expect(error).toBeInstanceOf(AnalysisCancelledError);
expect((error as AnalysisCancelledError).cause).toBe(reason);
} else expect(error).toMatchObject({ _tag: "ProviderAdapterError" });
expect(error).toMatchObject({ cleanupIncomplete: closeFails });
expect(f.resources.hasPending).toBe(closeFails);
await f.resources.retryCleanup();
expect(error).toMatchObject({ cleanupIncomplete: false });
expect(f.resources.hasPending).toBe(false);
} finally {
controls.file = undefined;
await f.resources.retryCleanup();
}
},
);
it.skipIf(process.platform === "win32")(
"publishes a verified state, reopens it, and separates target and analysis identities",
async () => {
const f = await fixture();
expect(f.selection.state).toEqual({ kind: "initial-analysis" });
expect(await f.owner.acquire("first")).toBeUndefined();
await f.owner.publish(f.packed);
await f.owner.close();
const loaded = await resolveGhidraCheckpoint(
f.root,
target,
profile,
f.resources,
);
expect(loaded.state).toEqual(f.selection.state);
const saved = f.owner.observation.source!;
const resumed = new GhidraCheckpoint(loaded, f.resources);
owners.push(resumed);
expect(await resumed.acquire("second")).toEqual({
path: join(loaded.directory, saved.file),
sha256: saved.sha256,
});
expect(
(
await resolveGhidraCheckpoint(
f.root,
"2".repeat(64),
profile,
f.resources,
)
).key,
).not.toBe(f.selection.key);
expect(
(
await resolveGhidraCheckpoint(
f.root,
target,
createAnalysisProfile(profile.provider, { import_mode: "other" }),
f.resources,
)
).key,
).not.toBe(f.selection.key);
},
);
it.skipIf(process.platform === "win32")(
"refuses a concurrent owner and admits it after the original owner closes",
async () => {
const f = await fixture();
await f.owner.acquire("first");
const other = new GhidraCheckpoint(f.selection, f.resources);
owners.push(other);
await expect(other.acquire("second")).rejects.toThrow(
/another REA session.*first/,
);
await f.owner.close();
expect(await other.acquire("second")).toBeUndefined();
},
);
it.skipIf(process.platform === "win32")(
"refuses state changed after profile resolution without replacing the current manifest",
async () => {
const f = await fixture();
await f.owner.acquire("first");
await f.owner.publish(f.packed);
const edited = join(f.root, "edited.gzf");
await writeFile(edited, "edited checkpoint");
await f.owner.publish(edited);
await f.owner.close();
const stale = new GhidraCheckpoint(f.selection, f.resources);
owners.push(stale);
const bytes = await readFile(
join(f.selection.directory, "current.json"),
"utf8",
);
await expect(stale.acquire("stale")).rejects.toThrow(
/changed after profile selection/,
);
await expect(stale.publish(f.packed)).rejects.toThrow(
/no admitted session lease/,
);
expect(
await readFile(join(f.selection.directory, "current.json"), "utf8"),
).toEqual(bytes);
},
);
it.skipIf(process.platform === "win32")(
"keeps initial identity stable and retains legacy data without guessing its origin",
async () => {
const f = await fixture();
await f.owner.acquire("initial");
await f.owner.publish(f.packed);
await f.owner.close();
const initial = await resolveGhidraCheckpoint(
f.root,
target,
profile,
f.resources,
);
expect(initial).toEqual(f.selection);
const path = join(initial.directory, "current.json");
const record = JSON.parse(await readFile(path, "utf8"));
expect(record.version).toBe(2);
expect(record.source.kind).toBe("initial-analysis");
const legacy = {
...record,
version: 1,
source: { file: record.source.file, sha256: record.source.sha256 },
};
await writeFile(path, JSON.stringify(legacy));
const selected = await resolveGhidraCheckpoint(
f.root,
target,
profile,
f.resources,
);
expect(selected.state).toEqual({
kind: "retained",
sha256: record.source.sha256,
});
const resumed = new GhidraCheckpoint(selected, f.resources);
owners.push(resumed);
expect((await resumed.acquire("legacy"))?.sha256).toBe(
record.source.sha256,
);
await resumed.close();
expect(JSON.parse(await readFile(path, "utf8"))).toEqual(legacy);
},
);
it.skipIf(process.platform === "win32")(
"rejects corruption, malformed manifests, and traversal rather than silently importing fresh",
async () => {
const f = await fixture();
await f.owner.acquire("first");
await f.owner.publish(f.packed);
await f.owner.close();
const manifestPath = join(f.selection.directory, "current.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
await writeFile(
join(f.selection.directory, manifest.source.file),
"corrupt",
);
await expect(
resolveGhidraCheckpoint(f.root, target, profile, f.resources),
).rejects.toThrow(/checksum/);
await writeFile(
manifestPath,
JSON.stringify({
...manifest,
source: { ...manifest.source, file: "../../foreign.gzf" },
}),
);
await expect(
resolveGhidraCheckpoint(f.root, target, profile, f.resources),
).rejects.toThrow();
await writeFile(manifestPath, "{");
await expect(
resolveGhidraCheckpoint(f.root, target, profile, f.resources),
).rejects.toThrow();
},
);
it.skipIf(process.platform === "win32")(
"keeps the prior checkpoint after a failed publication and succeeds on retry",
async () => {
const f = await fixture();
await f.owner.acquire("first");
await f.owner.publish(f.packed);
const path = join(f.selection.directory, "current.json"),
before = await readFile(path, "utf8");
const actual =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(rename).mockRejectedValueOnce(
new Error("controlled publish failure"),
);
const next = join(f.root, "next.gzf");
await writeFile(next, "new packed bytes");
await expect(f.owner.publish(next)).rejects.toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: { reason: "controlled publish failure" },
});
expect(await readFile(path, "utf8")).toEqual(before);
vi.mocked(rename).mockImplementation(actual.rename);
await f.owner.publish(next);
expect(await readFile(path, "utf8")).not.toEqual(before);
},
);
it.skipIf(process.platform === "win32")(
"retains publication and its old-file cleanup when cleanup fails, then retries without saving twice",
async () => {
const f = await fixture();
await f.owner.acquire("first");
await f.owner.publish(f.packed);
const old = f.owner.observation.source!;
const next = join(f.root, "next.gzf");
await writeFile(next, "changed");
const actual =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(rm).mockImplementation(async (path, options) => {
if (path === join(f.selection.directory, old.file))
throw new Error("controlled cleanup failure");
await actual.rm(path, options);
});
await expect(f.owner.publish(next)).rejects.toMatchObject({
cause: expect.objectContaining({
message: "controlled cleanup failure",
}),
});
const committed = await readFile(
join(f.selection.directory, "current.json"),
"utf8",
);
expect(f.owner.hasResources).toBe(true);
await expect(f.owner.close()).rejects.toMatchObject({
cause: expect.objectContaining({
message: "controlled cleanup failure",
}),
});
vi.mocked(rm).mockImplementation(actual.rm);
await f.owner.publish(next);
expect(
await readFile(join(f.selection.directory, "current.json"), "utf8"),
).toEqual(committed);
await f.owner.close();
expect(f.owner.hasResources).toBe(false);
},
);
it.skipIf(process.platform === "win32")(
"preserves a published checkpoint when cancellation is observed after the manifest commit",
async () => {
const f = await fixture();
await f.owner.acquire("first");
const controller = new AbortController();
const actual =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(rename).mockImplementationOnce(async (...args) => {
await actual.rename(...args);
controller.abort("after publication");
});
await expect(
f.owner.publish(f.packed, controller.signal),
).rejects.toThrow();
expect(f.owner.observation.source).not.toBeNull();
expect(
(await resolveGhidraCheckpoint(f.root, target, profile, f.resources))
.state,
).toEqual({ kind: "initial-analysis" });
},
);
it.skipIf(process.platform === "win32")(
"does not adopt a public checkpoint directory",
async () => {
const f = await fixture();
await mkdir(f.selection.directory, { mode: 0o755 });
await chmod(f.selection.directory, 0o755);
await expect(f.owner.acquire("first")).rejects.toThrow(/private directory/);
},
);
it.skipIf(process.platform === "win32")(
"keeps its lease socket short when checkpoint and inherited temporary paths are long",
async () => {
const f = await fixture();
const selection = await resolveGhidraCheckpoint(
join(f.root, "long-".repeat(24)),
target,
profile,
f.resources,
);
const owner = new GhidraCheckpoint(selection, f.resources);
owners.push(owner);
expect(await owner.acquire("long-directory")).toBeUndefined();
},
);
it.skipIf(process.platform === "win32")(
"preserves symlink/parent traversal and shares its lease with the physical directory",
async () => {
const f = await fixture();
await mkdir(join(f.root, "physical", "child"), { recursive: true });
await symlink(join(f.root, "physical", "child"), join(f.root, "link"));
const selected = `${f.root}/link/../selected`;
const selection = await resolveGhidraCheckpoint(
selected,
target,
profile,
f.resources,
);
expect(selection.directory.startsWith(selected)).toBe(true);
const owner = new GhidraCheckpoint(selection, f.resources);
owners.push(owner);
await owner.acquire("selected");
await owner.publish(f.packed);
await access(
join(f.root, "physical", "selected", selection.key, "current.json"),
);
await expect(
access(join(f.root, "selected", selection.key, "current.json")),
).rejects.toThrow();
const alias = await resolveGhidraCheckpoint(
join(f.root, "physical", "selected"),
target,
profile,
f.resources,
);
const peer = new GhidraCheckpoint(alias, f.resources);
owners.push(peer);
await expect(peer.acquire("physical")).rejects.toThrow(
/another REA session/,
);
await owner.close();
expect((await peer.acquire("physical"))?.sha256).toBe(
owner.observation.source?.sha256,
);
},
);
+434
View File
@@ -0,0 +1,434 @@
import { appendCallerPath } from "../filesystem/callerPath.js";
import { createHash, randomUUID } from "node:crypto";
import { constants } from "node:fs";
import {
copyFile,
lstat,
mkdir,
open,
realpath,
rename,
rm,
} from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
withArtifactFileHandle,
withArtifactRegularFile,
assertArtifactReadActive,
artifactFilePrimaryFacts,
mapArtifactFileFailure,
} from "../artifacts/ArtifactFileOperation.js";
import {
sameRegularFileState,
RegularFileChangedError,
} from "../filesystem/RegularFile.js";
import { acquireOwnedLease, type OwnedLease } from "../process/OwnedLease.js";
import {
ghidraFileFailure,
readGhidraProfileArtifact,
} from "./GhidraFileArtifacts.js";
const digest = z.string().regex(/^[a-f0-9]{64}$/);
const sourceIdentitySchema = z
.object({
file: z.string().regex(/^program-[a-f0-9-]{36}\.gzf$/),
sha256: digest,
})
.strict();
const sourceSchema = sourceIdentitySchema.extend({
kind: z.enum(["initial-analysis", "retained"]),
});
type CheckpointSource = z.infer<typeof sourceSchema>;
const manifestIdentitySchema = z.object({
target_sha256: digest,
base_profile_digest: digest,
});
const manifestSchema = z.union([
manifestIdentitySchema
.extend({ version: z.literal(2), source: sourceSchema })
.strict(),
// Earlier local checkpoints have no initial-generation proof. Retain their
// data, binding their identity to the verified content rather than guessing.
manifestIdentitySchema
.extend({ version: z.literal(1), source: sourceIdentitySchema })
.strict()
.transform((value) => ({
...value,
version: 2 as const,
source: { ...value.source, kind: "retained" as const },
})),
]);
const stateSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("initial-analysis") }).strict(),
z.object({ kind: z.literal("retained"), sha256: digest }).strict(),
]);
const checkpointState = (
source: CheckpointSource | null,
): z.infer<typeof stateSchema> =>
source === null || source.kind === "initial-analysis"
? { kind: "initial-analysis" }
: { kind: "retained", sha256: source.sha256 };
/** Semantic generation is stable through the first save; edited states bind their digest. */
export const ghidraCheckpointSelectionSchema = z
.object({
directory: z.string().min(1),
key: digest,
target_sha256: digest,
base_profile_digest: digest,
state: stateSchema,
})
.strict();
export type GhidraCheckpointSelection = z.infer<
typeof ghidraCheckpointSelectionSchema
>;
export interface GhidraPackedProgram {
readonly path: string;
readonly sha256: string;
}
const missing = (cause: unknown): boolean => {
const primary = artifactFilePrimaryFacts(cause).cause;
return (
primary instanceof Error && "code" in primary && primary.code === "ENOENT"
);
};
const failure = (directory: string, reason: string, cause?: unknown) =>
new ConfigurationError(
`Ghidra checkpoint at ${JSON.stringify(directory)}: ${reason}. Preserve this directory; correct its contents or select a new REA_GHIDRA_CHECKPOINT_DIR, then reopen the target.`,
{
cause,
settings: [{ setting: "REA_GHIDRA_CHECKPOINT_DIR", constraint: reason }],
},
);
/** Hash large packed Programs with one 64 KiB buffer and stable descriptor identity. */
export const hashGhidraPackedProgram = (
path: string,
resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<string> =>
withArtifactRegularFile(
path,
resources,
signal,
async ({ handle, initial }) => {
const hash = createHash("sha256"),
buffer = Buffer.allocUnsafe(64 * 1024);
for (let offset = 0; offset < initial.size;) {
assertArtifactReadActive(signal);
const { bytesRead } = await handle.read(
buffer,
0,
Math.min(buffer.length, initial.size - offset),
offset,
);
if (bytesRead === 0) throw new RegularFileChangedError(path);
hash.update(buffer.subarray(0, bytesRead));
offset += bytesRead;
}
if (!sameRegularFileState(initial, await handle.stat()))
throw new RegularFileChangedError(path);
return hash.digest("hex");
},
);
async function readSource(
selection: Omit<GhidraCheckpointSelection, "state">,
resources: ArtifactResourceScope,
signal?: AbortSignal,
) {
let manifest: z.infer<typeof manifestSchema>;
try {
manifest = await readGhidraProfileArtifact(
appendCallerPath(selection.directory, "current.json"),
64 * 1024,
resources,
signal,
(bytes) => manifestSchema.parse(JSON.parse(bytes.toString("utf8"))),
);
} catch (cause: unknown) {
if (missing(cause) && !resources.hasPending) return null;
throw ghidraFileFailure(
mapArtifactFileFailure(cause, (primary) =>
primary instanceof SyntaxError || primary instanceof z.ZodError
? failure(selection.directory, "the manifest is malformed", primary)
: primary,
),
"resolve_analysis_profile",
{ path: appendCallerPath(selection.directory, "current.json") },
);
}
if (
manifest.target_sha256 !== selection.target_sha256 ||
manifest.base_profile_digest !== selection.base_profile_digest
)
throw failure(
selection.directory,
"the manifest belongs to a different target or analysis profile",
);
const path = appendCallerPath(selection.directory, manifest.source.file);
let actual: string;
try {
actual = await hashGhidraPackedProgram(path, resources, signal);
} catch (cause: unknown) {
throw ghidraFileFailure(cause, "resolve_analysis_profile", { path });
}
if (actual !== manifest.source.sha256)
throw failure(
selection.directory,
"the packed Program checksum differs from its manifest",
);
return manifest.source;
}
/** Resolve storage and content identity once for the shared CLI/MCP analysis profile. */
export async function resolveGhidraCheckpoint(
root: string,
targetSha256: string,
profile: AnalysisProfileCommitment,
resources: ArtifactResourceScope,
signal?: AbortSignal,
): Promise<GhidraCheckpointSelection> {
const key = createHash("sha256")
.update(JSON.stringify([targetSha256, profile.digest]))
.digest("hex");
const selection = {
directory: appendCallerPath(root, key),
key,
target_sha256: targetSha256,
base_profile_digest: profile.digest,
};
return {
...selection,
state: checkpointState(await readSource(selection, resources, signal)),
};
}
/** One session owns publication, the socket lease, and any failed staging cleanup. */
export class GhidraCheckpoint {
#lease: OwnedLease | undefined;
#source: CheckpointSource | null | undefined;
#state: GhidraCheckpointSelection["state"];
readonly #pendingRemoval = new Set<string>();
readonly #cleanupFailures = new Set<ProviderCleanupError>();
#publishedInput: string | undefined;
constructor(
readonly selection: GhidraCheckpointSelection,
private readonly resources: ArtifactResourceScope,
) {
this.#state = selection.state;
}
get hasResources(): boolean {
return this.#lease !== undefined || this.#pendingRemoval.size > 0;
}
get cleanupResources(): readonly string[] {
return [
...(this.#lease === undefined
? []
: [this.#lease.socketPath ?? this.selection.directory]),
...this.#pendingRemoval,
];
}
get observation() {
return {
directory: this.selection.directory,
source: this.#source ?? null,
source_verified: this.#source !== undefined,
state: this.#state,
pending_cleanup: [...this.#pendingRemoval],
};
}
async acquire(
runId: string,
signal?: AbortSignal,
): Promise<GhidraPackedProgram | undefined> {
assertArtifactReadActive(signal);
await mkdir(this.selection.directory, { recursive: true, mode: 0o700 });
const state = await lstat(this.selection.directory);
if (
!state.isDirectory() ||
(state.mode & 0o077) !== 0 ||
(process.getuid !== undefined && state.uid !== process.getuid())
)
throw failure(
this.selection.directory,
"the checkpoint directory must be a private directory owned by this user",
);
const directory = await realpath(this.selection.directory);
const acquired = await acquireOwnedLease({
identity: { kind: "ghidra-checkpoint-v1", directory },
runId,
directory: join(
"/tmp",
`rea-ghidra-checkpoint-leases-${process.getuid?.() ?? "user"}`,
),
resources: this.resources,
failure: (reason, cause) =>
failure(
directory,
`lease ${reason.kind}${cause instanceof Error ? `: ${cause.message}` : ""}`,
cause,
),
});
if (!acquired.acquired)
throw failure(
directory,
`another REA session owns this checkpoint (run ${acquired.owner.runId}, process ${acquired.owner.processId}); close that session before retrying`,
);
this.#lease = acquired.lease;
this.#source = undefined;
const current = await readSource(this.selection, this.resources, signal);
const generation = checkpointState(current);
const aligned =
generation.kind === "initial-analysis"
? this.#state.kind === "initial-analysis"
: this.#state.kind === "retained" &&
this.#state.sha256 === generation.sha256;
if (!aligned)
throw failure(
directory,
"the checkpoint changed after profile selection; reopen the target",
);
assertArtifactReadActive(signal);
this.#source = current;
return current === null
? undefined
: { path: join(directory, current.file), sha256: current.sha256 };
}
/** Publish a new immutable blob, then atomically replace its manifest. */
async publish(path: string, signal?: AbortSignal): Promise<void> {
if (this.#lease === undefined || this.#source === undefined)
throw failure(
this.selection.directory,
"publication has no admitted session lease",
);
const previous = this.#source;
await this.retryCleanup();
if (this.#publishedInput === path) return;
const file = `program-${randomUUID()}.gzf`;
const destination = appendCallerPath(this.selection.directory, file);
const temporary = appendCallerPath(
this.selection.directory,
`.current-${randomUUID()}.json`,
);
this.#pendingRemoval.add(destination);
this.#pendingRemoval.add(temporary);
let primary: unknown;
let failed = false;
try {
assertArtifactReadActive(signal);
await copyFile(path, destination, constants.COPYFILE_EXCL);
const sha256 = await hashGhidraPackedProgram(
destination,
this.resources,
signal,
);
const source: CheckpointSource = {
file,
sha256,
kind: previous === null ? "initial-analysis" : "retained",
};
for (const [output, bytes] of [
[destination, undefined],
[
temporary,
JSON.stringify({
version: 2,
target_sha256: this.selection.target_sha256,
base_profile_digest: this.selection.base_profile_digest,
source,
}),
],
] as const) {
const handle = await open(
output,
bytes === undefined ? "r+" : "wx",
0o600,
);
await withArtifactFileHandle(
output,
handle,
this.resources,
signal,
async (handle) => {
if (bytes !== undefined) await handle.writeFile(bytes);
await handle.sync();
},
);
}
assertArtifactReadActive(signal);
await rename(
temporary,
appendCallerPath(this.selection.directory, "current.json"),
);
this.#source = source;
this.#state = checkpointState(source);
this.#publishedInput = path;
this.#pendingRemoval.delete(destination);
this.#pendingRemoval.delete(temporary);
if (previous !== null)
this.#pendingRemoval.add(
appendCallerPath(this.selection.directory, previous.file),
);
} catch (cause: unknown) {
primary = cause;
failed = true;
}
try {
await this.retryCleanup();
} catch (cause: unknown) {
if (failed)
throw analysisErrorWithCleanupFailure(
ghidraFileFailure(primary, "checkpoint_program", {
path: this.selection.directory,
}),
ghidraFileFailure(cause, "checkpoint_program", {
path: this.selection.directory,
}),
);
throw cause;
}
if (failed)
throw ghidraFileFailure(primary, "checkpoint_program", {
path: this.selection.directory,
});
assertArtifactReadActive(signal);
}
async retryCleanup(): Promise<void> {
for (const path of this.#pendingRemoval) {
try {
await rm(path, { force: true });
this.#pendingRemoval.delete(path);
} catch (cause: unknown) {
const error = new ProviderCleanupError(
"ghidra",
[...this.#pendingRemoval],
{
reason: `Checkpoint file cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`,
checkpoint: this.observation,
},
{ cause },
);
this.#cleanupFailures.add(error);
throw error;
}
}
for (const failure of this.#cleanupFailures) failure.resolveCleanup();
this.#cleanupFailures.clear();
}
async close(): Promise<void> {
await this.retryCleanup();
await this.#lease?.release();
this.#lease = undefined;
}
}
@@ -0,0 +1,39 @@
import { expect, it } from "vitest";
import {
ghidraDecompileTimeoutRecovery,
ghidraStartupTimeoutGuidance,
} from "./GhidraProviderClient.js";
it("names the saved Program when a finished invocation must restart after a decompile deadline", () => {
const recovery = ghidraDecompileTimeoutRecovery(10, "owned-checkpoint");
expect(recovery.remediationAction).toContain(
"restores the last saved Program from REA_GHIDRA_CHECKPOINT_DIR",
);
expect(recovery.remediationAction).toContain(
"if initial analysis never completed",
);
expect(recovery.remediationAction).not.toContain(
"repeats import and analysis",
);
expect(recovery.continuingRemediationAction).toContain(
"update_analysis_settings",
);
});
it("keeps initial analysis and later restore explicit in checkpoint startup recovery", () => {
const guidance = ghidraStartupTimeoutGuidance(250, "owned-checkpoint");
expect(guidance.userMessage).toContain(
"checkpoint restore or initial analysis and saving",
);
for (const action of [
guidance.remediationAction,
guidance.continuingCallerGuidance.remediationAction,
]) {
expect(action).toContain("Keep REA_GHIDRA_CHECKPOINT_DIR");
expect(action).toContain(
"initial analysis and its first checkpoint must complete once",
);
expect(action).not.toContain("repeats import from scratch");
expect(action).not.toContain("REA_GHIDRA_PROJECT_PATH");
}
});
+199 -28
View File
@@ -4,6 +4,9 @@ import {
type FileSnapshotPartialObservation,
} from "../domain/fileSnapshotObservation.js";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import type { Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import type { Socket } from "node:net";
@@ -110,6 +113,10 @@ import {
import { GhidraWire } from "./GhidraClientWire.js";
import { completeGhidraStartupHandshake } from "./GhidraClientStartup.js";
import { ghidraSessionRoot } from "./GhidraSessionRoot.js";
import {
GhidraCheckpoint,
type GhidraPackedProgram,
} from "./GhidraCheckpoint.js";
const SHUTDOWN_TIMEOUT_MS = 10_000;
const SESSION_ROOT = ghidraSessionRoot();
@@ -142,6 +149,11 @@ export class GhidraClient {
#snapshotPath: string | undefined;
#memoryCgroupBaseline: LinuxCgroupMemoryState | undefined;
readonly #fileResources = new ArtifactResourceScope();
readonly #checkpoint: GhidraCheckpoint | undefined;
#checkpointStaging:
| { readonly path: string; readonly disposition: "publish" | "discard" }
| undefined;
#checkpointRestore: GhidraPackedProgram | undefined;
readonly #cleanupFailures = new Set<AnalysisError>();
#targetAdmission: JsonValue | undefined;
#token: string | undefined;
@@ -187,6 +199,10 @@ export class GhidraClient {
this.#failAll(this.#failure("process", "Ghidra bridge socket closed"));
};
constructor(options: GhidraClientOptions) {
this.#checkpoint =
options.checkpoint === undefined
? undefined
: new GhidraCheckpoint(options.checkpoint, this.#fileResources);
this.#options = {
...options,
startupTimeoutMs:
@@ -723,6 +739,11 @@ export class GhidraClient {
}
}
/** Actual restore source, including a restart within the same REA session. */
checkpointRestore(): GhidraPackedProgram | undefined {
return this.#checkpointRestore;
}
/** Read completion from the current attempt; no independent readiness flag. */
#startupSucceeded(): boolean {
const startup = this.#startupAttempt?.settlement;
@@ -751,6 +772,7 @@ export class GhidraClient {
? (cause.cleanup?.resources ?? [])
: []),
...this.#fileResources.cleanupResources,
...(this.#checkpoint?.cleanupResources ?? []),
...[this.#runtimeRoot, this.#socketRoot].flatMap((root) =>
root === undefined ? [] : [root.path],
),
@@ -827,6 +849,7 @@ export class GhidraClient {
return [
...(client.#process === undefined ? [] : ["ghidra-process"]),
...client.#fileResources.cleanupResources,
...(client.#checkpoint?.cleanupResources ?? []),
...[client.#runtimeRoot, client.#socketRoot].flatMap((root) =>
root === undefined ? [] : [root.path],
),
@@ -906,13 +929,35 @@ export class GhidraClient {
this.#socket !== undefined ||
this.#runtimeRoot !== undefined ||
this.#socketRoot !== undefined ||
this.#fileResources.hasPending
this.#fileResources.hasPending ||
this.#checkpoint?.hasResources === true
);
}
async #startWithin(
deadline: ProviderStartupDeadline,
): Promise<GhidraStartResult> {
let packedProgram: GhidraPackedProgram | undefined;
try {
packedProgram = await this.#checkpoint?.acquire(
this.#options.runId ?? randomUUID(),
deadline.signal,
);
this.#checkpointRestore = packedProgram;
} catch (cause: unknown) {
return this.#startupFailure(
deadline.signal.aborted
? this.#interruptionFailure(deadline, cause)
: this.#failure(
"checkpoint",
cause instanceof Error
? cause.message
: "Ghidra checkpoint admission failed",
cause,
),
deadline,
);
}
try {
this.#runtimeRoot = await PrivateRuntimeRoot.create({
parent: SESSION_ROOT,
@@ -920,8 +965,9 @@ export class GhidraClient {
platform: this.#options.platform,
});
} catch (cause: unknown) {
return err(
return this.#startupFailure(
this.#failure("start", "Ghidra runtime root creation failed", cause),
deadline,
);
}
if (deadline.signal.aborted) return this.#startupInterrupted(deadline);
@@ -990,6 +1036,7 @@ export class GhidraClient {
targetSha256: this.#options.targetSha256,
providerVersion: this.#options.providerVersion,
profileDigest: this.#options.profileDigest,
...(packedProgram === undefined ? {} : { packedProgram }),
},
{ signal: deadline.signal, resourceScope: this.#fileResources },
)
@@ -1048,11 +1095,107 @@ export class GhidraClient {
);
}
if (deadline.signal.aborted) return this.#startupInterrupted(deadline);
if (this.#checkpoint !== undefined && packedProgram === undefined) {
try {
await this.#saveCheckpoint(true, deadline.signal);
} catch (cause: unknown) {
return this.#startupFailure(
deadline.signal.aborted
? this.#interruptionFailure(deadline, cause)
: this.#failure(
"checkpoint",
"Initial Ghidra checkpoint could not be saved; check REA_GHIDRA_CHECKPOINT_DIR and reopen the target",
cause,
),
deadline,
);
}
}
this.#startupOutputCheckpoint = undefined;
this.#requestQueue.reopen();
return completed;
}
/** Save once at startup and after changes, retaining a failed publication for retry. */
async #saveCheckpoint(force: boolean, signal?: AbortSignal): Promise<void> {
const owner = this.#checkpoint;
const runtime = this.#runtimeRoot;
if (owner === undefined || runtime === undefined) return;
await this.#finishCheckpointStaging(signal);
const path = join(runtime.path, `checkpoint-${randomUUID()}.gzf`);
const saved = await this.#wire.request(
"checkpoint_program",
{ path, force },
signal === undefined ? {} : { signal },
);
if (!saved.ok) {
if (saved.error.kind === "remote")
return this.#discardCheckpoint(path, saved.error, signal);
throw saved.error;
}
const parsed = z
.discriminatedUnion("saved", [
z.object({ saved: z.literal(false) }).strict(),
z.object({ saved: z.literal(true), path: z.literal(path) }).strict(),
])
.safeParse(saved.value);
if (!parsed.success)
return this.#discardCheckpoint(
path,
this.#failure(
"protocol",
"Ghidra checkpoint acknowledgement is invalid",
parsed.error,
),
signal,
);
if (!parsed.data.saved) return;
this.#checkpointStaging = { path, disposition: "publish" };
await this.#finishCheckpointStaging(signal);
}
/** An acknowledged failed export is owned but is not a valid checkpoint. */
async #discardCheckpoint(
path: string,
failure: GhidraSessionError,
signal?: AbortSignal,
): Promise<never> {
this.#checkpointStaging = { path, disposition: "discard" };
try {
await this.#finishCheckpointStaging(signal);
} catch (cause: unknown) {
const cleanup = this.#retainCleanupFailure(
new ProviderCleanupError(
"ghidra",
[path],
{
reason:
"A failed Ghidra checkpoint export left a partial file; close the target to retry its cleanup before another save.",
diagnostics: this.#diagnostics(),
},
{ cause },
),
);
throw failure.withCleanupFailure(cleanup);
}
throw failure;
}
async #finishCheckpointStaging(signal?: AbortSignal): Promise<void> {
const staged = this.#checkpointStaging;
if (staged === undefined) return;
if (staged.disposition === "publish") {
if (this.#checkpoint === undefined)
throw this.#failure(
"protocol",
"Checkpoint publication owner is missing",
);
await this.#checkpoint.publish(staged.path, signal);
}
await rm(staged.path, { force: true });
this.#checkpointStaging = undefined;
}
async #connectAndHandshake(
endpoint: GhidraEndpoint,
deadline: ProviderStartupDeadline,
@@ -1153,7 +1296,11 @@ export class GhidraClient {
await starting?.promise.catch(() => undefined);
await this.#cleanup(forceStop);
} finally {
if (this.#startupAttempt === starting) this.#startupAttempt = undefined;
if (
this.#startupAttempt === starting &&
(forceStop || this.#socket === undefined || this.#socket.destroyed)
)
this.#startupAttempt = undefined;
this.#closePromise = undefined;
}
}
@@ -1165,6 +1312,20 @@ export class GhidraClient {
this.#requestQueue.failQueued(
this.#failure("process", "Ghidra session closed"),
);
if (
!forceStop &&
this.#startupSucceeded() &&
this.#checkpoint !== undefined &&
this.#socket !== undefined &&
!this.#socket.destroyed
) {
try {
await this.#saveCheckpoint(false);
} catch (cause: unknown) {
this.#requestQueue.reopen();
throw cause;
}
}
const socket = this.#socket;
if (!forceStop && socket !== undefined && !socket.destroyed) {
const shutdown = await this.#wire
@@ -1219,6 +1380,7 @@ export class GhidraClient {
this.#lastDiagnostics = this.#diagnostics();
this.#process = undefined;
this.#launch = undefined;
await this.#checkpoint?.close();
await this.#fileResources.retryCleanup();
try {
await this.#removeRuntimeRoots();
@@ -1227,6 +1389,8 @@ export class GhidraClient {
else throw cause;
}
this.#snapshotPath = undefined;
this.#checkpointStaging = undefined;
this.#checkpointRestore = undefined;
this.#endpointPath = undefined;
this.#token = undefined;
this.#runId = undefined;
@@ -1401,32 +1565,39 @@ export class GhidraClient {
scoped === undefined
? undefined
: ghidraFailureOutput(scoped, this.#token, this.#outputDrainIncomplete);
return createGhidraDiagnostics({
scope,
targetPath: this.#options.targetPath,
targetSha256: this.#options.targetSha256,
...(this.#targetAdmission === undefined
return {
...createGhidraDiagnostics({
scope,
targetPath: this.#options.targetPath,
targetSha256: this.#options.targetSha256,
...(this.#targetAdmission === undefined
? {}
: { targetAdmission: this.#targetAdmission }),
transport: this.#options.transport,
providerVersion: this.#options.providerVersion,
profileDigest: this.#options.profileDigest,
...(this.#runId === undefined ? {} : { runId: this.#runId }),
...(this.#runtimeRoot === undefined
? {}
: { runtimeRoot: this.#runtimeRoot.path }),
...(this.#socketRoot === undefined
? {}
: { socketRoot: this.#socketRoot.path }),
...(this.#endpointPath === undefined
? {}
: { endpointPath: this.#endpointPath }),
...(this.#launch === undefined ? {} : { launch: this.#launch }),
...(snapshot === undefined ? {} : { snapshot }),
...(this.#token === undefined ? {} : { token: this.#token }),
previous: this.#lastDiagnostics,
...(failureOutput === undefined
? {}
: { failure_output: failureOutput }),
}),
...(this.#checkpoint === undefined
? {}
: { targetAdmission: this.#targetAdmission }),
transport: this.#options.transport,
providerVersion: this.#options.providerVersion,
profileDigest: this.#options.profileDigest,
...(this.#runId === undefined ? {} : { runId: this.#runId }),
...(this.#runtimeRoot === undefined
? {}
: { runtimeRoot: this.#runtimeRoot.path }),
...(this.#socketRoot === undefined
? {}
: { socketRoot: this.#socketRoot.path }),
...(this.#endpointPath === undefined
? {}
: { endpointPath: this.#endpointPath }),
...(this.#launch === undefined ? {} : { launch: this.#launch }),
...(snapshot === undefined ? {} : { snapshot }),
...(this.#token === undefined ? {} : { token: this.#token }),
previous: this.#lastDiagnostics,
...(failureOutput === undefined ? {} : { failure_output: failureOutput }),
});
: { checkpoint: this.#checkpoint.observation }),
};
}
#refreshProcessFailure(
+2
View File
@@ -1,4 +1,5 @@
import type { Logger } from "pino";
import type { GhidraCheckpointSelection } from "./GhidraCheckpoint.js";
import type { BridgeMaxRequestBytes } from "../config/bridgeMaxRequestBytes.js";
import type { Result } from "../domain/result.js";
import type { LinuxCgroupMemoryState } from "../process/LinuxCgroupMemory.js";
@@ -40,6 +41,7 @@ export interface GhidraClientOptions {
readonly targetPath: string;
readonly targetSha256: string;
readonly existingProject?: boolean;
readonly checkpoint?: GhidraCheckpointSelection;
/** Installation host shared by runtime allocation and snapshot admission. */
readonly platform?: NodeJS.Platform;
readonly transport?: GhidraTransportKind;
+35 -3
View File
@@ -1,5 +1,7 @@
import { GhidraAdmissionError } from "./GhidraAdmissionError.js";
import { copyExistingGhidraProject } from "./GhidraProjectSnapshot.js";
import { hashGhidraPackedProgram } from "./GhidraCheckpoint.js";
import { constants as fsConstants } from "node:fs";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js";
import {
@@ -9,7 +11,7 @@ import {
import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js";
import type { GhidraRiscvCallingConvention } from "./GhidraRiscvProfile.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { mkdir, readFile, realpath } from "node:fs/promises";
import { copyFile, mkdir, readFile, realpath } from "node:fs/promises";
import { basename, dirname, join, posix, win32 } from "node:path";
import { writeGhidraRuntimeFile } from "./GhidraRuntimeFile.js";
@@ -69,6 +71,7 @@ export interface GhidraLaunchSession {
readonly targetSha256: string;
readonly providerVersion: string;
readonly profileDigest: string;
readonly packedProgram?: import("./GhidraCheckpoint.js").GhidraPackedProgram;
}
/** Process plus private log coordinates returned by a Ghidra launcher. */
@@ -335,6 +338,29 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
platform,
this.options.configurationDirectoryName,
);
let packedProgramPath: string | undefined;
if (session.packedProgram !== undefined) {
if (options.resourceScope === undefined)
throw new GhidraLaunchError(
"Checkpoint restore requires its owning resource scope.",
);
packedProgramPath = join(session.runtimeRoot, "restored-program.gzf");
await copyFile(
session.packedProgram.path,
packedProgramPath,
fsConstants.COPYFILE_EXCL,
);
if (
(await hashGhidraPackedProgram(
packedProgramPath,
options.resourceScope,
options.signal,
)) !== session.packedProgram.sha256
)
throw new GhidraLaunchError(
"Ghidra checkpoint changed before its private import; reopen the target.",
);
}
// Copy-first: open the caller's project only through the owned copy so
// Ghidra never touches the original project files.
if (existingProject !== undefined)
@@ -426,6 +452,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
projectRoot: paths.projectRoot,
targetPath: session.targetPath,
...(existingProject === undefined ? {} : { existingProject }),
...(packedProgramPath === undefined ? {} : { packedProgramPath }),
bridgeScriptPath: this.options.bridgeScriptPath,
descriptorPath: paths.descriptorPath,
ghidraLogPath: paths.ghidraLogPath,
@@ -764,6 +791,7 @@ export interface GhidraHeadlessArgumentOptions {
readonly platform?: NodeJS.Platform;
readonly projectRoot: string;
readonly targetPath: string;
readonly packedProgramPath?: string;
/** Open this pre-imported program with -process instead of importing. */
readonly existingProject?: Pick<
GhidraExistingProject,
@@ -916,7 +944,7 @@ export const ghidraHeadlessArguments = (
...(existing === undefined
? [
"-import",
options.targetPath,
options.packedProgramPath ?? options.targetPath,
...(gamecubeDol !== undefined
? [
// One BinaryLoader block at file offset 0; the pre-script rebuilds the DOL section map.
@@ -984,7 +1012,11 @@ export const ghidraHeadlessArguments = (
// An existing project opens with -noanalysis: its auto-analysis was paid
// when the project was imported, outside this session's startup deadline.
...(existing === undefined
? ["-readOnly", "-deleteProject"]
? [
...(options.packedProgramPath === undefined ? [] : ["-noanalysis"]),
"-readOnly",
"-deleteProject",
]
: ["-noanalysis", "-readOnly"]),
"-log",
options.ghidraLogPath,
+52 -3
View File
@@ -3,6 +3,7 @@ import {
EXISTING_PROJECT_WINDOWS_REMEDIATION,
EXISTING_PROJECT_WINDOWS_UNAVAILABLE,
existingProjectCapabilities,
checkpointCapabilities,
ghidraEmptyIncomingReferencesLimitation,
ghidraEmptyDossierIncomingLimitation,
ghidraEmptyExternalOutgoingLimitation,
@@ -109,6 +110,48 @@ import { createEvidence } from "../domain/evidence.js";
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
it.skipIf(process.platform === "win32")(
"health describes checkpoint recovery without ephemeral reimport advice",
async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-checkpoint-health-"));
const ghidra = provider(
installationHost(),
() => ({
start: async () => ok(sessionInfo()),
callTool: async () => ok([]),
close: async () => ok(null),
checkpointRestore: () => ({
path: join(directory, "verified.gzf"),
sha256: "a".repeat(64),
}),
}),
{ REA_GHIDRA_CHECKPOINT_DIR: directory },
);
const client = await createElfClient(ghidra);
try {
const health = await client.execute("health", {});
if (!health.ok) throw health.error;
expect(health.value.limitations.join("\n")).toContain(
"last successfully saved owned Program",
);
expect(health.value.limitations.join("\n")).toContain(
"auto-analysis was not rerun",
);
expect(health.value.limitations.join("\n")).not.toMatch(
/imports the original artifact again|discards session annotations/,
);
for (const capability of checkpointCapabilities()) {
expect(capability.limitations.join("\n")).not.toMatch(
/discarded on close|imports the original artifact again/,
);
}
} finally {
await client.close();
await ghidra.closeOwnedResources();
await rm(directory, { recursive: true, force: true });
}
},
);
afterEach(() => vi.restoreAllMocks());
const installationHost = (
platform: NodeJS.Platform = "linux",
@@ -1840,7 +1883,10 @@ describe("Ghidra decompile deadline for a finished invocation", () => {
seconds,
existingProject,
);
const recovery = ghidraDecompileTimeoutRecovery(seconds, existingProject);
const recovery = ghidraDecompileTimeoutRecovery(
seconds,
existingProject ? "existing-project" : "fresh-import",
);
for (const projected of [continuing, invocation])
expect(projected).toMatchObject({
code: "provider_timeout",
@@ -4284,7 +4330,10 @@ describe("Ghidra startup deadline for a finished invocation", () => {
"invocation",
);
const continuing = await ghidraTimedOut("start", elapsed, environment);
const guidance = ghidraStartupTimeoutGuidance(250, existingProject);
const guidance = ghidraStartupTimeoutGuidance(
250,
existingProject ? "existing-project" : "fresh-import",
);
expect(invocation).toMatchObject({
code: "provider_timeout",
remediation: { action: guidance.remediationAction },
@@ -4332,7 +4381,7 @@ describe("Ghidra startup deadline for a finished invocation", () => {
}));
const result = await (await createElfClient(ghidra)).execute("health", {});
if (result.ok) throw new Error("Expected the composed startup failure");
const guidance = ghidraStartupTimeoutGuidance(250, false);
const guidance = ghidraStartupTimeoutGuidance(250, "fresh-import");
const child = (lifetime: AnalysisCallerLifetime) =>
projectAnalysisError(result.error, lifetime).details?.diagnostics;
expect(projectAnalysisError(result.error, "invocation")).toMatchObject({
+82 -20
View File
@@ -1,3 +1,13 @@
import { resolveGhidraCheckpoint } from "./GhidraCheckpoint.js";
import {
checkpointCapabilities,
CAPABILITIES,
existingProjectCapabilities,
EXISTING_PROJECT_WINDOWS_REMEDIATION,
EXISTING_PROJECT_WINDOWS_UNAVAILABLE,
windowsP0Capabilities,
WINDOWS_P0_NATIVE_PE_ARCHITECTURES,
} from "./GhidraProviderCapabilities.js";
import {
existingGhidraAnalysisProfile,
ghidraPinnedLanguage,
@@ -70,14 +80,6 @@ import {
isGhidraPspTarget,
} from "./GhidraPspProfile.js";
import { resolveGhidraExtensions } from "./extensions/GhidraExtensions.js";
import {
CAPABILITIES,
existingProjectCapabilities,
EXISTING_PROJECT_WINDOWS_REMEDIATION,
EXISTING_PROJECT_WINDOWS_UNAVAILABLE,
windowsP0Capabilities,
WINDOWS_P0_NATIVE_PE_ARCHITECTURES,
} from "./GhidraProviderCapabilities.js";
import { GHIDRA_PROVIDER_IDENTITY } from "./GhidraProviderMetadata.js";
import {
createGhidraProviderClient,
@@ -163,7 +165,9 @@ export class GhidraProvider implements AnalysisProviderCandidate {
? existingProjectCapabilities(platform)
: platform === "win32"
? windowsP0Capabilities()
: CAPABILITIES;
: this.config.ghidraCheckpointDirectory === undefined
? CAPABILITIES
: checkpointCapabilities();
}
async inspectAvailability(
@@ -206,7 +210,9 @@ export class GhidraProvider implements AnalysisProviderCandidate {
this.config.ghidraExistingProject !== undefined
? existingProjectCapabilities(installation.platform)
: readiness === undefined
? CAPABILITIES
? this.config.ghidraCheckpointDirectory === undefined
? CAPABILITIES
: checkpointCapabilities()
: windowsP0Capabilities(readiness),
};
}
@@ -501,19 +507,75 @@ export class GhidraProvider implements AnalysisProviderCandidate {
options?.signal,
);
if (!seeds.ok) return seeds;
if (extensions.value.length === 0 && seeds.value === undefined)
if (
extensions.value.length === 0 &&
seeds.value === undefined &&
this.config.ghidraCheckpointDirectory === undefined
)
return resolved;
const baseProfile = createAnalysisProfile(resolved.value.profile.provider, {
...resolved.value.profile.parameters,
...(extensions.value.length === 0
? {}
: { analysis_extensions: jsonValueSchema.parse(extensions.value) }),
...(seeds.value === undefined
? {}
: { analysis_seeds: jsonValueSchema.parse(seeds.value) }),
});
const checkpointDirectory = this.config.ghidraCheckpointDirectory;
if (
checkpointDirectory !== undefined &&
(installation.platform === "win32" ||
target.kind !== "executable" ||
!(
target.format === "elf" ||
target.format === "pe" ||
isMachOFormat(target.format)
) ||
ghidraPinnedLanguage(target) !== undefined ||
extensions.value.length !== 0 ||
baseProfile.parameters.loader !== "auto-from-header" ||
baseProfile.parameters.psp_extension !== undefined ||
seeds.value !== undefined ||
this.config.ghidraLanguageOverride !== undefined ||
this.config.ghidraDisabledAnalyzers !== undefined)
)
return err(
new ConfigurationError(
"Ghidra checkpoints currently support stock ELF/PE/Mach-O imports on Linux/macOS",
{
settings: [
{
setting: "REA_GHIDRA_CHECKPOINT_DIR",
constraint:
"Select a stock native import on Linux/macOS, or unset this setting for the existing ephemeral workflow.",
},
],
},
),
);
const checkpoint =
checkpointDirectory === undefined
? undefined
: await resolveGhidraCheckpoint(
checkpointDirectory,
target.sha256,
baseProfile,
this.#inspectionResources,
options?.signal,
);
return ok({
...resolved.value,
profile: createAnalysisProfile(resolved.value.profile.provider, {
...resolved.value.profile.parameters,
...(extensions.value.length === 0
? {}
: { analysis_extensions: jsonValueSchema.parse(extensions.value) }),
...(seeds.value === undefined
? {}
: { analysis_seeds: jsonValueSchema.parse(seeds.value) }),
}),
...(checkpoint === undefined
? {}
: { refreshAfterRetirement: true as const }),
profile:
checkpoint === undefined
? baseProfile
: createAnalysisProfile(baseProfile.provider, {
...baseProfile.parameters,
owned_checkpoint: jsonValueSchema.parse(checkpoint),
}),
});
}
+30 -8
View File
@@ -33,17 +33,28 @@ export const windowsP0Limitations = Object.freeze([
"Windows sessions require the matching packaged Windows x64 native addon, local NTFS targets and runtimes, and Windows 10 or later. Native handles enforce path admission, private DACLs, and Job Object ownership automatically.",
]);
export type GhidraSessionSource =
| "fresh-import"
| "existing-project"
| "owned-checkpoint";
/** Existing databases have their own unknown analysis history; reopening does not complete it. */
export const ghidraHealthLimitations = (
existingProject = false,
source: GhidraSessionSource = "fresh-import",
): readonly string[] =>
existingProject
source === "existing-project"
? [
"This session opens a caller-selected existing Ghidra project through a private copy, without import, auto-analysis or repair scripts. Original analysis completeness and settings are unknown.",
"The original project is never opened by Ghidra or modified. The private copy is read-only to bridge operations and deleted on close; original executable bytes are never written.",
"The analysis profile records the selected project content fingerprint and program name. The imported program SHA-256 must match the admitted target; the project may contain caller annotations or earlier edits, which are not fresh REA discoveries.",
]
: healthLimitations;
: source === "owned-checkpoint"
? [
"Opt-in Ghidra checkpoints retain the last successfully saved owned Program, including its names, comments and types. Restoring a checkpoint skips auto-analysis; its analysis is retained state, not a fresh discovery.",
"REA saves initial completed analysis and saves metadata changes on normal close. A failed save retains the live session for retry. Cancellation or process failure can lose changes after the last successful save; unfinished initial auto-analysis is not resumable.",
"The live project is a temporary working copy. Checkpoints persist in REA_GHIDRA_CHECKPOINT_DIR; source executables and caller-selected source projects are not modified.",
]
: healthLimitations;
const stringInventoryLimitation =
"Only Ghidra-defined string Data is observed; packed or undefined string bytes may be absent. Completeness applies to that inventory, and an empty search does not establish that text is absent from the artifact. If an address is known, use read_bytes and xrefs to inspect bytes and references.";
@@ -82,10 +93,10 @@ export const ghidraEmptyInstructionOutgoingLimitation =
/** Limitation text for one admitted operation, including the common base. */
export const limitationsFor = (
operation: string,
existingProject = false,
source: GhidraSessionSource = "fresh-import",
): readonly string[] => {
const common = [
...ghidraHealthLimitations(existingProject),
...ghidraHealthLimitations(source),
"Default-space addresses use lowercase 0x-prefixed hexadecimal; other address spaces use <percent-encoded-space>:0x<hex>.",
];
switch (operation) {
@@ -101,7 +112,9 @@ export const limitationsFor = (
...common,
"Names use Ghidra USER_DEFINED source. Name writes accept a leaf name or a fully qualified name within the existing namespace; readback uses the fully qualified name and can be reused without adding namespace prefixes. Edits retain the current namespace; other namespace-like text remains literal leaf-name text. Regular comments map to PRE and inline comments to EOL at the exact function entry. Changes commit together after readback and refreshed analysis; failure rolls them all back.",
"Annotation text rejects NUL and unpaired Unicode surrogates before mutation, with the field and UTF-16 index in the error. Supported Unicode and line endings are preserved.",
"Metadata edits invalidate immutable analysis snapshots and are discarded on close. CLI returns the updated dossier before session cleanup; this is not a saved Ghidra project.",
source === "owned-checkpoint"
? "Metadata edits invalidate immutable analysis snapshots. Normal close saves edits to the owned checkpoint; cancellation or process failure can lose edits after the last successful save. CLI returns the updated dossier before cleanup."
: "Metadata edits invalidate immutable analysis snapshots and are discarded on close. CLI returns the updated dossier before session cleanup; this is not a saved Ghidra project.",
];
case "set_address_name":
case "set_addresses_names":
@@ -111,7 +124,9 @@ export const limitationsFor = (
operation === "set_address_name"
? "A name held by another address in the destination namespace is not moved; a false result reports its reason in raw_result.failures."
: "set_addresses_names decides every address from the names held before the batch: addresses requesting one destination name, or a name another selected address holds, are rejected rather than ordered. Others apply in their own transactions. raw_result.failures gives each failure's reason; raw_result.conflicts lists each contested name once with its requesting and holding addresses.",
"Metadata edits invalidate immutable analysis snapshots and are discarded on close; this is not a saved Ghidra project.",
source === "owned-checkpoint"
? "Metadata edits invalidate immutable analysis snapshots. Normal close saves edits to the owned checkpoint; cancellation or process failure can lose edits after the last successful save."
: "Metadata edits invalidate immutable analysis snapshots and are discarded on close; this is not a saved Ghidra project.",
];
case "inspect_native_load_image":
return [...common, ghidraLoadImageVerificationNote()];
@@ -241,6 +256,13 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
}),
);
/** Preserve operation-specific caveats while describing the opted-in lifecycle. */
export const checkpointCapabilities = (): readonly CapabilityDescriptor[] =>
CAPABILITIES.map((capability) => ({
...capability,
limitations: limitationsFor(capability.operation, "owned-checkpoint"),
}));
/** The selected database mode is not part of Windows P0 authority. */
export const EXISTING_PROJECT_WINDOWS_UNAVAILABLE =
"An existing Ghidra project cannot be opened on the Windows launch path.";
@@ -258,7 +280,7 @@ export const existingProjectCapabilities = (
capability.operation === "binary_overview"
? "read_binary_inventory_summary"
: capability.operation,
true,
"existing-project",
);
if (platform === "win32")
return {
+99 -42
View File
@@ -1,10 +1,12 @@
import { filterGhidraLiteralCandidates } from "./GhidraLiteralSearch.js";
import { ghidraCheckpointSelectionSchema } from "./GhidraCheckpoint.js";
import { ghidraProjectHistoryEvidence } from "./GhidraProjectHistory.js";
import type { Evidence, EvidenceLocation } from "../domain/evidence.js";
import { ghidraProjectCommitmentSchema } from "./GhidraProjectSnapshot.js";
import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js";
import { MAX_GHIDRA_DECOMPILE_TIMEOUT_SECONDS } from "../domain/analysisResourceLimits.js";
import { z } from "zod";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { artifactFilePrimaryFacts } from "../artifacts/ArtifactFileOperation.js";
import { fileURLToPath } from "node:url";
@@ -62,7 +64,7 @@ import {
AnalysisTimeoutError,
resourceConstraintRecovery,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
import {
providerRetryAction,
@@ -109,6 +111,7 @@ import type { NativeAotPeResult } from "../domain/native/nativeAotPe.js";
import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js";
import {
ghidraHealthLimitations,
type GhidraSessionSource,
ghidraEmptyXrefsLimitation,
ghidraEmptyIncomingReferencesLimitation,
ghidraEmptyDossierIncomingLimitation,
@@ -147,6 +150,7 @@ export type GhidraProviderClientFactory = (
Pick<
GhidraClient,
| "callToolIfReady"
| "checkpointRestore"
| "operationHealthSnapshot"
| "runtimeLineage"
| "readTargetSnapshot"
@@ -288,6 +292,22 @@ export const createGhidraProviderClient = (input: {
const prerequisites = ghidraClientPrerequisites(profile, installation);
if (!prerequisites.ok) return err(prerequisites.error);
const committedProfile = prerequisites.value.profile;
const checkpoint = ghidraCheckpointSelectionSchema
.optional()
.safeParse(committedProfile.parameters.owned_checkpoint);
if (
!checkpoint.success ||
(config.ghidraCheckpointDirectory !== undefined) !==
(checkpoint.data !== undefined)
)
return err(
new ProviderAdapterError("ghidra", "open_binary", {
diagnostics: {
reason:
"Resolve REA_GHIDRA_CHECKPOINT_DIR into the analysis profile before opening the session.",
},
}),
);
const psp = isGhidraPspTarget(target);
const pspExtension = psp
? ghidraPspExtensionSchema.safeParse(
@@ -378,6 +398,12 @@ export const createGhidraProviderClient = (input: {
const projection: GhidraProjection = {
limits: config,
programName: project?.program_name,
source:
project !== undefined
? "existing-project"
: checkpoint.data !== undefined
? "owned-checkpoint"
: "fresh-import",
};
const seedProfile = ghidraSeedCommitmentSchema
.optional()
@@ -556,6 +582,7 @@ export const createGhidraProviderClient = (input: {
}),
);
const client = clientFactory({
...(checkpoint.data === undefined ? {} : { checkpoint: checkpoint.data }),
startupTimeoutMs: config.ghidraStartupTimeoutMs,
readStartupTimeoutMs: () => config.ghidraStartupTimeoutMs,
bridgeMaxRequestBytes: config.bridgeMaxRequestBytes,
@@ -789,18 +816,26 @@ export const createGhidraProviderClient = (input: {
const releaseLimitation = unverifiedGhidraBuildLimitation(
prerequisites.value.providerVersion,
);
const sessionLimitations = (): readonly string[] => [
...providerLimitations,
...targetLimitations,
...(psp ? GHIDRA_PSP_LIMITATIONS : []),
...softFloatLimitations,
...riscvCallingConventionLimitations,
...ghidraExtensionLimitations(extensions),
...seedLimitations,
...disabledAnalyzerLimitations,
...pdbLimitations,
...(releaseLimitation === undefined ? [] : [releaseLimitation]),
];
const sessionLimitations = (): readonly string[] => {
const restored = client.checkpointRestore?.();
return [
...(restored === undefined
? []
: [
`Restored owned Program ${restored.path} (SHA-256 ${restored.sha256}); auto-analysis was not rerun.`,
]),
...providerLimitations,
...targetLimitations,
...(psp ? GHIDRA_PSP_LIMITATIONS : []),
...softFloatLimitations,
...riscvCallingConventionLimitations,
...ghidraExtensionLimitations(extensions),
...seedLimitations,
...disabledAnalyzerLimitations,
...pdbLimitations,
...(releaseLimitation === undefined ? [] : [releaseLimitation]),
];
};
const nativeAotOwner =
target.format === "pe"
? new GhidraNativeAotSnapshotOwner({
@@ -828,7 +863,7 @@ export const createGhidraProviderClient = (input: {
}),
analysisProfile: committedProfile,
limitations: [
...limitationsFor(operation, project !== undefined),
...limitationsFor(operation, projection.source),
...(observation.limitations ?? []),
...sessionLimitations(),
],
@@ -925,7 +960,7 @@ export const createGhidraProviderClient = (input: {
createAnalysisExecution(started.value, committedProfile.provider, {
analysisProfile: committedProfile,
limitations: [
...ghidraHealthLimitations(project !== undefined),
...ghidraHealthLimitations(projection.source),
...sessionLimitations(),
],
}),
@@ -1165,6 +1200,7 @@ type GhidraProjectionLimits = Pick<
interface GhidraProjection {
readonly limits: GhidraProjectionLimits;
readonly programName: string | undefined;
readonly source: GhidraSessionSource;
}
const projectSessionError = (
@@ -1193,7 +1229,7 @@ const projectSessionError = (
const projectPrimarySessionError = (
operation: AnalysisOperation,
failure: GhidraSessionError,
{ limits, programName }: GhidraProjection,
{ limits, programName, source }: GhidraProjection,
health?: ProviderOperationHealth,
request?: Readonly<Record<string, JsonValue>>,
): AnalysisError => {
@@ -1205,6 +1241,22 @@ const projectPrimarySessionError = (
? {}
: { partialObservation: failure.partialObservation }),
};
if (failure.kind === "checkpoint") {
if (failure.cause instanceof AnalysisError)
return analysisClientFailureForOperation(
failure.cause,
"ghidra",
operation,
);
return new ConfigurationError(failure.message, {
...context,
settings: [
{ setting: "REA_GHIDRA_CHECKPOINT_DIR", constraint: failure.message },
],
remediationAction:
"Preserve the checkpoint directory. Correct the reported file, permission or competing-session problem, then reopen the target. Select a new checkpoint directory only if you explicitly want fresh analysis.",
});
}
// 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.
@@ -1260,7 +1312,7 @@ const projectPrimarySessionError = (
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)
if (source === "existing-project")
return new ConfigurationError(constraint, {
...context,
settings: [{ setting: "REA_GHIDRA_PROJECT_PATH", constraint }],
@@ -1319,7 +1371,7 @@ const projectPrimarySessionError = (
default:
recovery =
"Use list_procedures or search_procedures (CLI: `rea search <path> <pattern> --kind procedures --provider ghidra --json`) to inspect the current Ghidra inventory and choose an observed name or address. An absent entry does not prove the function is absent from the artifact." +
(limits.ghidraExistingProject !== undefined
(source === "existing-project"
? " The selected project's previous analysis completeness is unknown. To explicitly request a separate fresh import, start a new REA process without REA_GHIDRA_PROJECT_PATH and REA_GHIDRA_PROGRAM_NAME; that analysis will not include the selected project's annotations or other edits."
: "");
}
@@ -1361,7 +1413,7 @@ const projectPrimarySessionError = (
failure,
"Only the decompiler failed for this procedure.",
GHIDRA_SESSION_RETAINED,
limits.ghidraExistingProject !== undefined,
source,
),
remediationAction:
"Call analyze_function (CLI: `rea function`) for the same procedure. It returns assembly, callers, callees, references and basic blocks from the Ghidra listing and records this decompiler failure under facet_availability.pseudocode. Repeating the unchanged decompile request is not expected to succeed.",
@@ -1378,11 +1430,11 @@ const projectPrimarySessionError = (
failure,
"Only this procedure's decompilation was stopped.",
GHIDRA_SESSION_RETAINED,
limits.ghidraExistingProject !== undefined,
source,
);
const recovery = ghidraDecompileTimeoutRecovery(
limits.ghidraDecompileTimeoutSeconds,
limits.ghidraExistingProject !== undefined,
source,
);
return new AnalysisTimeoutError(
operation,
@@ -1417,7 +1469,7 @@ const projectPrimarySessionError = (
failure,
undefined,
GHIDRA_ANNOTATIONS_RETAINED,
limits.ghidraExistingProject !== undefined,
source,
);
return new AnalysisResourceConstraintError(
operation,
@@ -1503,7 +1555,7 @@ const projectPrimarySessionError = (
remediationAction: GHIDRA_RESTRICTED_TEMP_REMEDIATION,
});
const killed = projectSigkill(operation, failure, context, {
restart: exitedProviderMessage(operation, failure, health),
restart: exitedProviderMessage(operation, failure, health, source),
uncertainty,
});
if (killed !== undefined) return killed;
@@ -1511,10 +1563,7 @@ const projectPrimarySessionError = (
const timeoutMs = failure.timeoutMs ?? startupTimeoutMs;
const guidance =
failure.message === GHIDRA_STARTUP_DEADLINE_ELAPSED
? ghidraStartupTimeoutGuidance(
timeoutMs,
limits.ghidraExistingProject !== undefined,
)
? ghidraStartupTimeoutGuidance(timeoutMs, source)
: undefined;
return new AnalysisTimeoutError(operation, timeoutMs, {
...context,
@@ -1529,7 +1578,7 @@ const projectPrimarySessionError = (
}),
});
}
const exited = exitedProviderMessage(operation, failure, health);
const exited = exitedProviderMessage(operation, failure, health, source);
if (exited !== undefined)
return new ProviderAdapterError("ghidra", operation, {
...context,
@@ -1708,10 +1757,12 @@ export const GHIDRA_ANNOTATIONS_RETAINED =
"The active analysis session and annotations remain available.";
/** What a new Ghidra session repeats in the configured startup mode. */
const ghidraSessionRestart = (existingProject: boolean): string =>
existingProject
const ghidraSessionRestart = (source: GhidraSessionSource): string =>
source === "existing-project"
? "copies and opens the selected REA_GHIDRA_PROJECT_PATH project again, without import or auto-analysis"
: "repeats import and analysis";
: source === "owned-checkpoint"
? "restores the last saved Program from REA_GHIDRA_CHECKPOINT_DIR; if initial analysis never completed, it must finish once before a checkpoint exists"
: "repeats import and analysis";
/**
* Decompile-deadline recovery for each caller lifetime. A finished invocation
@@ -1720,7 +1771,7 @@ const ghidraSessionRestart = (existingProject: boolean): string =>
*/
export const ghidraDecompileTimeoutRecovery = (
seconds: number,
existingProject: boolean,
source: GhidraSessionSource,
): {
readonly remediationAction: string;
readonly continuingRemediationAction: string;
@@ -1729,7 +1780,7 @@ export const ghidraDecompileTimeoutRecovery = (
seconds < MAX_GHIDRA_DECOMPILE_TIMEOUT_SECONDS
? `Set REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS above ${String(seconds)} seconds (maximum ${String(MAX_GHIDRA_DECOMPILE_TIMEOUT_SECONDS)}), or unset it to leave decompilation unbounded,`
: `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS is already at its maximum of ${String(seconds)} seconds; unset it to leave decompilation unbounded`
} in the environment of a new REA invocation, then rerun the command. That invocation starts a new Ghidra session and ${ghidraSessionRestart(existingProject)}. Use \`rea function\` for this procedure's listing facts without pseudocode.`,
} in the environment of a new REA invocation, then rerun the command. That invocation starts a new Ghidra session and ${ghidraSessionRestart(source)}. Use \`rea function\` for this procedure's listing facts without pseudocode.`,
continuingRemediationAction:
"Use analyze_function (CLI: `rea function`) for listing facts. Call update_analysis_settings with setting ghidra.decompile_timeout_seconds and action set to raise the per-function deadline for the next operation on this Program. For CLI or future REA processes, set REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS. Set the MCP tool-call deadline to cover startup and the full call; earlier cancellation closes the session. `rea setup` estimates deadlines for new Codex/Kimi registrations and preserves existing positive values; edit existing deadlines yourself.",
});
@@ -1743,7 +1794,7 @@ const ghidraRequestOutcome = (
failure: GhidraSessionError,
requestScope: string | undefined,
retainedSession: string,
existingProject: boolean,
source: GhidraSessionSource,
): {
readonly userMessage: string;
readonly continuingCallerGuidance?: { readonly userMessage: string };
@@ -1758,7 +1809,7 @@ const ghidraRequestOutcome = (
return {
userMessage: `${detail} ${
failure.cleanupFailure === undefined
? `The request was also cancelled before the failure settled, so REA closed the Ghidra session; the next request starts a new session and ${ghidraSessionRestart(existingProject)}.`
? `The request was also cancelled before the failure settled, so REA closed the Ghidra session; the next request starts a new session and ${ghidraSessionRestart(source)}.`
: "The request was also cancelled, and Ghidra session shutdown left cleanup unconfirmed. Close the target to retry retained cleanup before starting another Ghidra session."
}`,
};
@@ -1839,7 +1890,7 @@ const projectSigkill = (
/** Name the settings that change Ghidra's startup deadline for the configured startup mode. */
export const ghidraStartupTimeoutGuidance = (
timeoutMs: number,
existingProject: boolean,
source: GhidraSessionSource,
) => {
const remediation = (continuing: boolean): string => {
const raise = continuing
@@ -1852,9 +1903,11 @@ export const ghidraStartupTimeoutGuidance = (
? " The MCP client's tool-call deadline must also allow startup and the query."
: "";
const reopen = continuing ? "Reopening" : "Each invocation";
return existingProject
return source === "existing-project"
? `Large projects can need longer: ${raise}. Existing-project mode does not run auto-analysis, so analyzer settings do not shorten this startup. ${retry}${client}`
: `Large binaries can need longer: ${raise}; REA_GHIDRA_DISABLED_ANALYZERS can skip selected expensive analyzers with reduced coverage. ${retry} ${reopen} repeats import from scratch; for repeated work on the same native ELF, PE or Mach-O binary on a POSIX host, analyze it once into a Ghidra project and set REA_GHIDRA_PROJECT_PATH to reuse that analysis.${client}`;
: source === "owned-checkpoint"
? `Checkpoint startup can need longer: ${raise}. Restoring a saved Program skips auto-analysis; initial analysis and its first checkpoint must complete once. ${retry} Keep REA_GHIDRA_CHECKPOINT_DIR to recover the last successful save. Analyzer overrides require explicitly disabling checkpoint mode.${client}`
: `Large binaries can need longer: ${raise}; REA_GHIDRA_DISABLED_ANALYZERS can skip selected expensive analyzers with reduced coverage. ${retry} ${reopen} repeats import from scratch; for repeated work on the same native ELF, PE or Mach-O binary on a POSIX host, analyze it once into a Ghidra project and set REA_GHIDRA_PROJECT_PATH to reuse that analysis.${client}`;
};
return {
limit: analysisSettingLimit(
@@ -1862,9 +1915,12 @@ export const ghidraStartupTimeoutGuidance = (
timeoutMs,
undefined,
),
userMessage: existingProject
? `Ghidra did not finish copying and opening the selected project within its configured ${String(timeoutMs)} ms startup deadline (REA_GHIDRA_STARTUP_TIMEOUT_MS).`
: `Ghidra did not finish importing and analyzing the target within its configured ${String(timeoutMs)} ms startup deadline (REA_GHIDRA_STARTUP_TIMEOUT_MS).`,
userMessage:
source === "existing-project"
? `Ghidra did not finish copying and opening the selected project within its configured ${String(timeoutMs)} ms startup deadline (REA_GHIDRA_STARTUP_TIMEOUT_MS).`
: source === "owned-checkpoint"
? `Ghidra did not finish checkpoint restore or initial analysis and saving within its configured ${String(timeoutMs)} ms startup deadline (REA_GHIDRA_STARTUP_TIMEOUT_MS).`
: `Ghidra did not finish importing and analyzing the target within its configured ${String(timeoutMs)} ms startup deadline (REA_GHIDRA_STARTUP_TIMEOUT_MS).`,
remediationAction: remediation(false),
continuingCallerGuidance: { remediationAction: remediation(true) },
};
@@ -1875,6 +1931,7 @@ const exitedProviderMessage = (
operation: AnalysisOperation,
failure: GhidraSessionError,
health: ProviderOperationHealth | undefined,
source: GhidraSessionSource,
): string | undefined => {
if (failure.kind !== "process" && failure.kind !== "protocol")
return undefined;
@@ -1887,5 +1944,5 @@ const exitedProviderMessage = (
health.state === "exited"
? `exited${health.exitCode === null ? "" : ` with code ${String(health.exitCode)}`}`
: "became unreachable";
return `The Ghidra provider for this session ${observed} before ${operation} could complete. Call open_binary for the same target to start a new Ghidra session, then retry; session-only annotations from the previous provider are not retained.`;
return `The Ghidra provider for this session ${observed} before ${operation} could complete. Call open_binary for the same target to start a new Ghidra session, then retry; ${source === "owned-checkpoint" ? "the last successful checkpoint is restored, and later unsaved annotations are not retained" : "session-only annotations from the previous provider are not retained"}.`;
};
+1
View File
@@ -29,6 +29,7 @@ export type GhidraSessionFailureKind =
| "start"
| "process"
| "protocol"
| "checkpoint"
| "remote"
| "analysis_timeout";
@@ -138,6 +138,34 @@ async function installation() {
describe.skipIf(process.platform === "win32")(
"PSP installed-extension and profile boundary",
() => {
it("refuses an otherwise supported custom PSP loader in stock checkpoint mode", async () => {
const f = await installation();
const checkpoints = new GhidraProvider(
{
...f.config,
ghidraCheckpointDirectory: join(f.directory, "checkpoints"),
},
silentLogger,
{},
host,
);
try {
const supported = await f.provider.resolveAnalysisProfile(target);
if (!supported.ok) throw supported.error;
expect(supported.value.profile?.parameters.loader).toBe("PspElfLoader");
const refused = await checkpoints.resolveAnalysisProfile(target);
expect(refused).toMatchObject({
ok: false,
error: {
_tag: "ConfigurationError",
settings: [{ setting: "REA_GHIDRA_CHECKPOINT_DIR" }],
},
});
} finally {
await checkpoints.closeOwnedResources();
await f.provider.closeOwnedResources();
}
});
it("binds source interpretation and installed content, not mtimes or discovery order", async () => {
const { provider, root } = await installation();
expect(provider.inspectTargetSupport(target).status).toBe("supported");
@@ -110,7 +110,7 @@ it("tells a finished CLI invocation to rerun with the launch setting, not to reu
);
// The invocation has already released the Program the guidance describes.
await expectReleased(launcher);
const recovery = ghidraDecompileTimeoutRecovery(1, false);
const recovery = ghidraDecompileTimeoutRecovery(1, "fresh-import");
expect(projected).toMatchObject({
error: "Analysis failed",
code: "provider_timeout",
@@ -152,7 +152,7 @@ it("keeps same-session and runtime-update guidance for a continuing MCP connecti
retryable: true,
recovery: { kind: "inspect_timeout_state", timeout_ms: 1000 },
remediation: {
action: ghidraDecompileTimeoutRecovery(1, false)
action: ghidraDecompileTimeoutRecovery(1, "fresh-import")
.continuingRemediationAction,
},
details: {
@@ -0,0 +1,285 @@
import { afterEach, expect, it, vi } from "vitest";
import { createHash } from "node:crypto";
import {
access,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js";
import { createAnalysisProfile } from "../../../../src/domain/analysisProfile.js";
import { GhidraClient } from "../../../../src/ghidra/GhidraClient.js";
import { resolveGhidraCheckpoint } from "../../../../src/ghidra/GhidraCheckpoint.js";
import { GhidraFixtureLauncher } from "./ghidraClient.fixture.js";
import { awaitAcknowledgement } from "../../../fixtures/lifecycleAcknowledgement.js";
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return { ...actual, rm: vi.fn(actual.rm) };
});
const roots: string[] = [],
clients: GhidraClient[] = [],
launchers: GhidraFixtureLauncher[] = [];
afterEach(async () => {
const actual =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(rm).mockReset().mockImplementation(actual.rm);
for (const launcher of launchers.splice(0))
for (const child of launcher.processes)
if (child.connected) child.send("release_checkpoint");
for (const client of clients.splice(0)) await client.close();
for (const root of roots.splice(0))
await rm(root, { recursive: true, force: true });
});
it.skipIf(process.platform === "win32")(
"reopens after startup publication succeeds but staging removal fails",
async () => {
const f = await fixture();
const actual =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
let failed = false;
vi.mocked(rm).mockImplementation(async (path, options) => {
if (
!failed &&
typeof path === "string" &&
/checkpoint-[a-f0-9-]+\.gzf$/.test(path)
) {
failed = true;
throw new Error("controlled staging removal failure");
}
await actual.rm(path, options);
});
expect((await f.client.start()).ok).toBe(false);
await access(join(f.selection.directory, "current.json"));
expect((await f.client.start()).ok).toBe(true);
expect((await f.client.close()).ok).toBe(true);
for (const root of f.launcher.runtimeRoots)
await expect(access(root)).rejects.toThrow();
},
);
const profile = createAnalysisProfile(
{ id: "ghidra", name: "Ghidra", version: "12.1.4" },
{ checkpoint_test: true },
);
async function fixture(
mode: ConstructorParameters<typeof GhidraFixtureLauncher>[0] = "checkpoint",
) {
const root = await mkdtemp(join(tmpdir(), "rea-checkpoint-lifecycle-"));
roots.push(root);
const targetPath = join(root, "target");
await writeFile(targetPath, "source target bytes");
const sha256 = createHash("sha256")
.update("source target bytes")
.digest("hex");
const selection = await resolveGhidraCheckpoint(
root,
sha256,
profile,
new ArtifactResourceScope(),
);
const launcher = new GhidraFixtureLauncher(mode);
launchers.push(launcher);
const client = new GhidraClient({
launcher,
targetPath,
targetSha256: sha256,
providerVersion: "12.1.4",
profileDigest: profile.digest,
checkpoint: selection,
startupTimeoutMs: 10_000,
});
clients.push(client);
return { root, selection, launcher, client, targetPath, sha256 };
}
it.skipIf(process.platform === "win32")(
"saves completed initial analysis, leaves an unchanged checkpoint alone, and releases its lease",
async () => {
const f = await fixture();
expect((await f.client.start()).ok).toBe(true);
const path = join(f.selection.directory, "current.json"),
before = await readFile(path, "utf8");
expect((await f.client.close()).ok).toBe(true);
expect(await readFile(path, "utf8")).toEqual(before);
expect(f.launcher.requests).toContain("checkpoint_program");
const selection = await resolveGhidraCheckpoint(
f.root,
f.sha256,
profile,
new ArtifactResourceScope(),
);
const resumed = new GhidraClient({
launcher: f.launcher,
targetPath: f.targetPath,
targetSha256: f.sha256,
providerVersion: "12.1.4",
profileDigest: profile.digest,
checkpoint: selection,
startupTimeoutMs: 10_000,
});
clients.push(resumed);
expect((await resumed.start()).ok).toBe(true);
expect(f.launcher.packedPrograms.at(-1)?.sha256).toBe(
JSON.parse(await readFile(path, "utf8")).source.sha256,
);
},
);
it.skipIf(process.platform === "win32")(
"keeps a failed normal-close save editable and retries without deleting the previous checkpoint",
async () => {
const f = await fixture("checkpoint_fail_close");
expect((await f.client.start()).ok).toBe(true);
const path = join(f.selection.directory, "current.json"),
before = await readFile(path, "utf8");
expect(
(
await f.client.callTool("set_address_name", {
address: "0x1000",
name: "changed",
})
).ok,
).toBe(true);
expect((await f.client.close()).ok).toBe(false);
expect(
(await readdir(f.launcher.runtimeRoots[0]!)).filter((name) =>
name.startsWith("checkpoint-"),
),
).toEqual([]);
expect(f.client.isReady()).toBe(true);
expect(await readFile(path, "utf8")).toEqual(before);
expect((await f.client.callTool("list_documents", {})).ok).toBe(true);
expect((await f.client.close()).ok).toBe(true);
expect(await readFile(path, "utf8")).not.toEqual(before);
},
);
it.skipIf(process.platform === "win32")(
"does not release a Program or its lease before its final checkpoint acknowledges",
async () => {
const f = await fixture("checkpoint_hold_close");
expect((await f.client.start()).ok).toBe(true);
await f.client.callTool("set_address_name", {
address: "0x1000",
name: "changed",
});
const closing = f.client.close();
await awaitAcknowledgement(
f.launcher.checkpointHeld,
closing,
"final checkpoint",
);
expect(f.launcher.processes[0]!.exitCode).toBeNull();
const selection = await resolveGhidraCheckpoint(
f.root,
f.sha256,
profile,
new ArtifactResourceScope(),
);
const other = new GhidraClient({
launcher: f.launcher,
targetPath: f.targetPath,
targetSha256: f.sha256,
providerVersion: "12.1.4",
profileDigest: profile.digest,
checkpoint: selection,
});
clients.push(other);
expect((await other.start()).ok).toBe(false);
f.launcher.processes[0]!.send("release_checkpoint");
expect((await closing).ok).toBe(true);
},
);
it.skipIf(process.platform === "win32")(
"retains a failed partial-file cleanup and retries it before exporting again",
async () => {
const f = await fixture("checkpoint_fail_close");
expect((await f.client.start()).ok).toBe(true);
const manifestPath = join(f.selection.directory, "current.json");
const before = await readFile(manifestPath, "utf8");
await f.client.callTool("set_address_name", {
address: "0x1000",
name: "changed",
});
const actual =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
vi.mocked(rm).mockImplementation(async (path, options) => {
if (typeof path === "string" && /checkpoint-[a-f0-9-]+\.gzf$/.test(path))
throw new Error("controlled partial cleanup failure");
await actual.rm(path, options);
});
const first = await f.client.close();
expect(first.ok).toBe(false);
expect((await f.client.close()).ok).toBe(false);
expect(await readFile(manifestPath, "utf8")).toBe(before);
expect(
(await readdir(f.launcher.runtimeRoots[0]!)).filter((name) =>
name.startsWith("checkpoint-"),
),
).toHaveLength(1);
if (first.ok) throw new Error("partial cleanup unexpectedly succeeded");
expect(first.error.cleanupResources).toContain(f.launcher.runtimeRoots[0]);
expect(f.client.isReady()).toBe(true);
vi.mocked(rm).mockImplementation(actual.rm);
expect((await f.client.close()).ok).toBe(true);
expect(first.error.cleanupResources).toEqual([]);
},
);
it.skipIf(process.platform === "win32")(
"lets a startup peer finish after another waiter cancels during initial checkpointing",
async () => {
const f = await fixture("checkpoint_hold_startup"),
abort = new AbortController();
const first = f.client.start(abort.signal),
peer = f.client.start();
const child = await f.launcher.started;
await awaitAcknowledgement(
f.launcher.checkpointHeld,
peer,
"initial checkpoint",
);
abort.abort("only this waiter");
expect((await first).ok).toBe(false);
child.send("release_checkpoint");
expect((await peer).ok).toBe(true);
expect(f.client.isReady()).toBe(true);
},
);
it.skipIf(process.platform === "win32")(
"cancels the last startup waiter without publishing a partial checkpoint and removes its runtime",
async () => {
const f = await fixture("checkpoint_hold_startup"),
abort = new AbortController();
const starting = f.client.start(abort.signal),
child = await f.launcher.started;
await awaitAcknowledgement(
f.launcher.checkpointHeld,
starting,
"initial checkpoint",
);
abort.abort("last waiter");
expect((await starting).ok).toBe(false);
expect((await f.client.close()).ok).toBe(true);
await expect(
access(join(f.selection.directory, "current.json")),
).rejects.toThrow();
for (const root of f.launcher.runtimeRoots)
await expect(access(root)).rejects.toThrow();
expect(child.exitCode !== null || child.signalCode !== null).toBe(true);
},
);
@@ -15,6 +15,10 @@ export const ghidraFixturePath = fileURLToPath(
);
type FixtureMode =
| "checkpoint"
| "checkpoint_fail_close"
| "checkpoint_hold_startup"
| "checkpoint_hold_close"
| "unicode_search"
| "reject_first_connection"
| "reject_all_connections"
@@ -60,10 +64,16 @@ export class GhidraFixtureLauncher implements GhidraLauncher {
readonly tokens: string[] = [];
readonly processes: ChildProcess[] = [];
readonly requests: string[] = [];
readonly packedPrograms: GhidraLaunchSession["packedProgram"][] = [];
#notifyCheckpointHeld: (() => void) | undefined;
readonly checkpointHeld = new Promise<void>((resolve) => {
this.#notifyCheckpointHeld = resolve;
});
constructor(readonly mode: FixtureMode = "success") {}
async launch(session: GhidraLaunchSession) {
this.packedPrograms.push(session.packedProgram);
this.runtimeRoots.push(session.runtimeRoot);
this.endpointPaths.push(session.endpointPath);
this.tokens.push(session.token);
@@ -86,6 +96,13 @@ export class GhidraFixtureLauncher implements GhidraLauncher {
},
});
process_.on("message", (value: unknown) => {
if (
typeof value === "object" &&
value !== null &&
"event" in value &&
value.event === "checkpoint-held"
)
this.#notifyCheckpointHeld?.();
if (
typeof value === "object" &&
value !== null &&
+70
View File
@@ -178,6 +178,19 @@ const handleOutputFailure = (socket, state) => {
};
const handleRequest = (socket, server, request, state) => {
if (state.mode.startsWith("checkpoint")) {
if (request.method === "set_address_name") {
state.checkpointDirty = true;
socket.write(
JSON.stringify({ id: request.id, ok: true, result: "renamed" }) + "\n",
);
return;
}
if (request.method === "checkpoint_program") {
void handleCheckpoint(socket, request, state);
return;
}
}
if (request.method === "ping") {
handlePing(socket, request, state);
return;
@@ -374,6 +387,63 @@ const handleRequest = (socket, server, request, state) => {
}
};
async function handleCheckpoint(socket, request, state) {
const { path, force } = request.params;
if (!force && !state.checkpointDirty) {
socket.write(
JSON.stringify({ id: request.id, ok: true, result: { saved: false } }) +
"\n",
);
return;
}
if (
!force &&
state.mode === "checkpoint_fail_close" &&
!state.failedCheckpoint
) {
state.failedCheckpoint = true;
await writeFile(path, "controlled incomplete packed Program");
socket.write(
JSON.stringify({
id: request.id,
ok: false,
error: {
code: "checkpoint_failed",
message: "Controlled checkpoint failure",
},
}) + "\n",
);
return;
}
await writeFile(
path,
`fixture checkpoint ${state.checkpointDirty ? "edited" : "initial"}`,
);
if (
(force && state.mode === "checkpoint_hold_startup") ||
(!force && state.mode === "checkpoint_hold_close")
) {
await new Promise((resolve) => {
const release = (message) => {
if (message === "release_checkpoint") {
process.off("message", release);
resolve();
}
};
process.on("message", release);
process.send?.({ event: "checkpoint-held" });
});
}
state.checkpointDirty = false;
socket.write(
JSON.stringify({
id: request.id,
ok: true,
result: { saved: true, path },
}) + "\n",
);
}
const handlePing = (socket, request, state) => {
if (
["import_identity_unavailable", "import_identity_mismatch"].includes(