fix(ghidra): preserve source failures and snapshot ownership

This commit is contained in:
morluto 2026-10-09 05:10:31 +08:00
parent 2f59287f1a
commit 704fa7699c
8 changed files with 316 additions and 62 deletions

View File

@ -110,6 +110,16 @@ After metadata edits, snapshot saves and imports remain unavailable in that
session. Use `export_evidence_bundle` to retain the observations, close without
`snapshot_path`, and reopen the target before importing or saving a snapshot.
Ghidra checks its private import copy against the artifact digest selected by
`open_binary`. If the source changes before that copy is acquired, the query
reports `artifact_changed` with the selected path and both digests. Reopen the
stable target to acquire its current identity. An already imported database
continues to describe its captured bytes even if the original path is changed
or removed; this identity failure does not mark the Ghidra installation unavailable.
On Linux and macOS, an unreadable source reports `access_denied`; a removed or
nonregular replacement reports `artifact_changed`. Source copying is cancellable
and never replaces an existing private snapshot.
```bash
rea inspect-native-instruction <binary> <address> --provider ghidra
rea resolve-native-call-targets <binary> <call-site> --provider ghidra

View File

@ -308,6 +308,18 @@ snapshot is written by an independent real CLI session.
They also start a real annotation and snapshot close concurrently: the edit
must succeed, the snapshot must be rejected without creating a file, and the
edited session must remain usable until explicitly closed.
Source-admission probes change a caller-owned fixture after `open_binary` but
before the first Ghidra query. They require an actionable `artifact_changed`
error preserving both digests and the selected path, unchanged provider
availability, failed-copy cleanup, and successful recovery after reopening.
After import, deleting that source must preserve the captured database identity.
Instruction inspection and containing-function lookup also agree across
hexadecimal case, leading zeros, and encoded default address-space spellings.
The same source-acquisition workflow exercises missing and directory-replaced
inputs, plus real read-permission denial on a non-root host. Root runs report
that permission-denial check as unverified. A focused filesystem integration
check retains selected-platform routing and exclusive creation; two copy/digest
checks duplicated by the real workflow have been removed.
Malformed annotation readback, memory completeness, and inventory data remain separate
SDK/provider integration cases; success from a real
provider cannot establish rejection of a contradictory provider response.

View File

@ -10,6 +10,7 @@ import addFormats from "ajv-formats";
import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs";
import { verifyLegacyGhidraReferenceSnapshot } from "./ghidra-reference-snapshot-e2e.mjs";
import { verifyGhidraSnapshotLifecycle } from "./real-ghidra-snapshot-lifecycle.mjs";
import { verifyGhidraTargetAdmission } from "./real-ghidra-target-admission.mjs";
/** Probe real Ghidra location, annotation and error contracts through public adapters. */
export async function verifyGhidraBoundaries(
@ -648,6 +649,13 @@ export async function verifyGhidraBoundaries(
);
successfulCalls += snapshotLifecycle.successfulCalls;
rejectedCalls += snapshotLifecycle.rejectedCalls;
const targetAdmission = await verifyGhidraTargetAdmission({
call,
reject: invalid,
target,
entry: address,
runtimeRoot: env.TMPDIR,
});
await assert.rejects(access(socketRoot), { code: "ENOENT" });
await assert.rejects(access(runtimeRoot), { code: "ENOENT" });
assert.equal(await call("procedure_address", { procedure: name }), address);
@ -683,6 +691,11 @@ export async function verifyGhidraBoundaries(
legacy_reference_snapshot_rejected: true,
mutation_snapshot_lifecycle: true,
concurrent_annotation_snapshot_close: true,
changed_source_admission_and_recovery: true,
source_permission_denial_verified: targetAdmission.permissionDenied,
missing_and_nonregular_source_rejected: true,
imported_source_identity_retained: true,
equivalent_instruction_address_spellings: true,
source_immutable: true,
reopen_discards_edits: true,
long_tmpdir_private_socket_cleanup: true,

View File

@ -0,0 +1,191 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import {
chmod,
mkdir,
mkdtemp,
readFile,
realpath,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
/** Verify changed-source admission and retained private database identity through MCP. */
export async function verifyGhidraTargetAdmission({
call,
reject,
target,
entry,
runtimeRoot,
}) {
const workspace = await mkdtemp(
join(tmpdir(), "rea-ghidra-source-admission-"),
);
const source = join(workspace, "caller-selected.%PATH%");
try {
const original = await readFile(target.path);
await writeFile(source, original);
await call("close_binary");
const opened = await call("open_binary", {
path: source,
provider_id: "ghidra",
});
assert.equal(opened.sha256, target.sha256);
const selectedPath = await realpath(source);
assert.equal(opened.path, selectedPath);
const before = await call("binary_session");
const changed = Buffer.concat([
original,
Buffer.from("REA_SOURCE_CHANGED_AFTER_OPEN"),
]);
const changedSha256 = createHash("sha256").update(changed).digest("hex");
await writeFile(source, changed);
const failure = await reject("analyze_function", { procedure: entry });
assert.equal(failure.code, "artifact_changed");
assert.equal(failure.category, "integrity_mismatch");
assert.equal(failure.details.operation, "analyze_function");
assert.equal(failure.details.path, selectedPath);
assert.ok(failure.details.reason.includes(target.sha256));
assert.ok(failure.details.reason.includes(changedSha256));
assert.match(failure.remediation.action, /open_binary/u);
assert.doesNotMatch(failure.remediation.action, /doctor/u);
const after = await call("binary_session");
assert.equal(after.analysis_run.run_id, before.analysis_run.run_id);
assert.equal(after.sha256, target.sha256);
assert.equal(
after.capabilities.find(
({ operation }) => operation === "analyze_function",
)?.available,
true,
"An artifact identity mismatch must not mark the installed provider unavailable",
);
assert.deepEqual(
(await readdir(runtimeRoot)).filter((name) =>
name.startsWith("rea-ghidra-"),
),
[],
"Failed admission must remove the private snapshot without launching Ghidra",
);
assert.deepEqual(await readFile(source), changed);
const permissionDenied = await verifySourceReadFailures({
call,
reject,
source,
bytes: changed,
entry,
runtimeRoot,
});
const reopened = await call("open_binary", {
path: source,
provider_id: "ghidra",
});
assert.equal(reopened.sha256, changedSha256);
assert.notEqual(
(await call("binary_session")).analysis_run.run_id,
before.analysis_run.run_id,
);
const dossier = await call("analyze_function", { procedure: entry });
const instruction = await call("inspect_native_instruction", {
address: entry,
});
assert.equal(dossier.procedure.address, entry);
assert.equal(instruction.status, "decoded");
for (const address of [
entry.toUpperCase(),
entry.slice(2),
`0x0000${entry.slice(2)}`,
`ram:${entry}`,
`%72%61%6D:${entry}`,
]) {
assert.deepEqual(
await call("inspect_native_instruction", { address }),
instruction,
);
assert.equal(
(await call("resolve_containing_procedure", { address })).procedure
.address,
entry,
);
}
// Imported facts belong to the captured bytes even if the caller's path disappears.
await rm(source);
assert.deepEqual(
await call("analyze_function", { procedure: entry }),
dossier,
);
assert.deepEqual(
await call("inspect_native_instruction", { address: entry }),
instruction,
);
assert.equal((await call("binary_session")).sha256, changedSha256);
await call("close_binary");
await call("open_binary", { path: target.path, provider_id: "ghidra" });
return { permissionDenied };
} finally {
await rm(workspace, { recursive: true, force: true });
}
}
async function verifySourceReadFailures({
call,
reject,
source,
bytes,
entry,
runtimeRoot,
}) {
const permissionDenied =
typeof process.getuid === "function" && process.getuid() !== 0;
const scenarios = [
"missing",
"directory",
...(permissionDenied ? ["permission"] : []),
];
for (const scenario of scenarios) {
await call("open_binary", { path: source, provider_id: "ghidra" });
const before = await call("binary_session");
if (scenario === "permission") await chmod(source, 0);
else {
await rm(source);
if (scenario === "directory") await mkdir(source);
}
try {
const failure = await reject("analyze_function", { procedure: entry });
assert.equal(
failure.code,
scenario === "permission" ? "access_denied" : "artifact_changed",
);
assert.equal(failure.details.path, before.path);
assert.equal(failure.details.operation, "analyze_function");
if (scenario === "permission") {
assert.ok(["EACCES", "EPERM"].includes(failure.details.system_code));
assert.match(failure.remediation.action, /read access/u);
} else assert.match(failure.remediation.action, /open_binary/u);
assert.doesNotMatch(failure.remediation.action, /doctor/u);
const after = await call("binary_session");
assert.equal(after.analysis_run.run_id, before.analysis_run.run_id);
assert.equal(after.sha256, before.sha256);
assert.equal(
after.capabilities.find(
({ operation }) => operation === "analyze_function",
)?.available,
true,
);
assert.deepEqual(
(await readdir(runtimeRoot)).filter((name) =>
name.startsWith("rea-ghidra-"),
),
[],
);
} finally {
if (scenario === "permission") await chmod(source, 0o600);
await rm(source, { recursive: true, force: true });
await writeFile(source, bytes);
}
}
return permissionDenied;
}

View File

@ -75,7 +75,7 @@ export const analysisErrorRemediationAction = (
if (error instanceof AnalysisAccessDeniedError)
return "Check the current process's read access to the selected path. Retry with a readable local file.";
if (error instanceof AnalysisArtifactChangedError)
return "Wait until the selected file is stable, then retry this operation.";
return "Wait until the selected file is stable. For an active binary session, reopen the target with open_binary before retrying so REA acquires its current identity; for a CLI command or target-free tool, rerun the operation.";
if (error instanceof AnalysisInputError)
return "Correct the listed arguments and retry.";
if (error instanceof UnknownRegistryError && error.reason === "not-found")

View File

@ -11,6 +11,8 @@ import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import {
AnalysisCancelledError,
AnalysisArtifactChangedError,
AnalysisAccessDeniedError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisResourceConstraintError,
@ -319,6 +321,20 @@ const projectSessionError = (
operation: AnalysisOperation,
failure: GhidraSessionError,
): AnalysisError => {
if (failure.cause instanceof AnalysisAccessDeniedError)
return new AnalysisAccessDeniedError(
operation,
failure.cause.path,
failure.cause.systemCode,
{ cause: failure },
);
if (failure.cause instanceof AnalysisArtifactChangedError)
return new AnalysisArtifactChangedError(
operation,
failure.cause.path,
failure.cause.reason,
{ cause: failure },
);
if (
operation === "annotate_native_function" &&
failure.kind === "remote" &&

View File

@ -1,9 +1,15 @@
import { createHash } from "node:crypto";
import { constants, createReadStream } from "node:fs";
import { chmod, copyFile, rm } from "node:fs/promises";
import { constants } from "node:fs";
import { open, rm, type FileHandle } from "node:fs/promises";
import { extname, join } from "node:path";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { JsonValue } from "../domain/jsonValue.js";
import {
AnalysisArtifactChangedError,
AnalysisAccessDeniedError,
} from "../domain/analysisErrorCore.js";
import { windowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js";
/** Immutable target copy admitted to one ephemeral Ghidra import. */
@ -37,7 +43,9 @@ export const createGhidraTargetSnapshot = async (
options.signal,
);
if (observed.sha256 !== expectedSha256)
throw new Error(
throw new AnalysisArtifactChangedError(
"open_binary",
sourcePath,
`Ghidra target snapshot digest mismatch: expected ${expectedSha256}, observed ${observed.sha256}`,
);
return {
@ -46,23 +54,71 @@ export const createGhidraTargetSnapshot = async (
admission: { source: observed.source, snapshot: observed.snapshot },
};
}
let source: FileHandle | undefined;
let destination: FileHandle | undefined;
try {
await copyFile(sourcePath, snapshotPath, constants.COPYFILE_EXCL);
await chmod(snapshotPath, 0o600);
source = await openSnapshotSource(sourcePath);
if (!(await source.stat()).isFile())
throw new AnalysisArtifactChangedError(
"open_binary",
sourcePath,
"The selected Ghidra source is no longer a regular file",
);
destination = await open(snapshotPath, "wx", 0o600);
await destination.chmod(0o600);
const hash = createHash("sha256");
for await (const chunk of createReadStream(snapshotPath))
hash.update(chunk);
const digest = new Transform({
transform(chunk: unknown, _encoding, done) {
if (!Buffer.isBuffer(chunk)) {
done(new TypeError("Ghidra snapshot stream must contain bytes"));
return;
}
hash.update(chunk);
done(null, chunk);
},
});
await pipeline(
source.createReadStream(),
digest,
destination.createWriteStream(),
options.signal === undefined ? {} : { signal: options.signal },
);
const observedSha256 = hash.digest("hex");
if (observedSha256 !== expectedSha256)
throw new Error(
throw new AnalysisArtifactChangedError(
"open_binary",
sourcePath,
`Ghidra target snapshot digest mismatch: expected ${expectedSha256}, observed ${observedSha256}`,
);
return { path: snapshotPath, sha256: observedSha256 };
} catch (cause: unknown) {
await rm(snapshotPath, { force: true }).catch((cause: unknown) => {
// best-effort cleanup: snapshot removal must not mask the copy failure.
void cause;
});
if (destination !== undefined)
await rm(snapshotPath, { force: true }).catch((cause: unknown) => {
// best-effort cleanup: snapshot removal must not mask the copy failure.
void cause;
});
throw cause;
} finally {
await Promise.all([source?.close(), destination?.close()]);
}
};
const openSnapshotSource = async (path: string): Promise<FileHandle> => {
try {
// A path replaced by a FIFO must not block startup before its type is checked.
return await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
} catch (cause: unknown) {
const code =
cause instanceof Error && "code" in cause ? cause.code : undefined;
if (code === "EACCES" || code === "EPERM")
throw new AnalysisAccessDeniedError("open_binary", path, code, { cause });
if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP")
throw new AnalysisArtifactChangedError(
"open_binary",
path,
`The selected Ghidra source could not be opened (${code})`,
{ cause },
);
throw cause;
}
};

View File

@ -1,20 +1,16 @@
import { createHash } from "node:crypto";
import { access, readFile, rm, writeFile } from "node:fs/promises";
import { readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { createGhidraTestRuntime } from "../../../fixtures/ghidraRuntime.js";
import type { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
import { createGhidraTargetSnapshot } from "../../../../src/ghidra/GhidraTargetSnapshot.js";
const roots: string[] = [];
const runtimes: PrivateRuntimeRoot[] = [];
afterEach(async () => {
await Promise.all(runtimes.splice(0).map((runtime) => runtime.close()));
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
@ -33,52 +29,12 @@ describe("Ghidra target snapshot", () => {
});
expect(snapshot.admission).toBeUndefined();
await expect(readFile(snapshot.path)).resolves.toEqual(bytes);
await expect(
createGhidraTargetSnapshot(source, root, sha256, { platform: "linux" }),
).rejects.toMatchObject({ code: "EEXIST" });
await expect(readFile(snapshot.path)).resolves.toEqual(bytes);
await expect(
createGhidraTargetSnapshot(source, root, sha256, { platform: "win32" }),
).rejects.toThrow(/No native private runtime owns/u);
});
it("copies an exact digest-bound target into the private runtime", async () => {
const root = await createTestTempDirectory("rea-ghidra-snapshot-");
roots.push(root);
const source = join(root, "source.exe");
const bytes = Buffer.from("native PE fixture");
const sha256 = createHash("sha256").update(bytes).digest("hex");
await writeFile(source, bytes);
const runtime = await createGhidraTestRuntime(root);
runtimes.push(runtime);
const snapshot = await createGhidraTargetSnapshot(
source,
runtime.path,
sha256,
);
expect(snapshot).toMatchObject({
path: join(runtime.path, `target-${sha256.slice(0, 12)}.exe`),
sha256,
});
await expect(readFile(snapshot.path)).resolves.toEqual(bytes);
});
it("rejects a mismatched digest and removes the failed runtime snapshot", async () => {
const root = await createTestTempDirectory("rea-ghidra-snapshot-");
roots.push(root);
const source = join(root, "source with unsafe extension.%PATH%");
await writeFile(source, "changed target");
const runtime = await createGhidraTestRuntime(root);
runtimes.push(runtime);
const expected = "a".repeat(64);
const snapshotPath = join(
runtime.path,
`target-${expected.slice(0, 12)}.bin`,
);
await expect(
createGhidraTargetSnapshot(source, runtime.path, expected),
).rejects.toThrow(/digest mismatch/u);
await runtime.close();
await expect(access(snapshotPath)).rejects.toMatchObject({
code: "ENOENT",
});
});
});