mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5b62fe820 | ||
|
|
e5af91e689 | ||
|
|
5d0ba95b62 | ||
|
|
e61a0b093a | ||
|
|
ec37c2adfb | ||
|
|
9ee5c9c603 | ||
|
|
979b751dbb | ||
|
|
a9d3247573 | ||
|
|
9679109ecd |
No files matched your search
@@ -179,6 +179,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private List<FunctionEntry> procedureInventory;
|
||||
private List<InventoryItem> stringInventory;
|
||||
private DecompInterface decompiler;
|
||||
private boolean existingProject;
|
||||
// A refused Program may only report its admission failure to the client.
|
||||
private String identityAdmissionFailure;
|
||||
/** Per-function native decompiler limit from the session; 0 leaves it unbounded. */
|
||||
@@ -199,6 +200,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
Path descriptorPath = Path.of(arguments[0]);
|
||||
checkpointDirectory = descriptorPath.toAbsolutePath().normalize().getParent();
|
||||
SessionDescriptor descriptor = readDescriptor(descriptorPath);
|
||||
existingProject = descriptor.existingProject;
|
||||
decompileTimeoutSeconds = descriptor.decompileTimeoutSeconds();
|
||||
// Windows retains a native immutable bearer descriptor lease until
|
||||
// runtime_close; POSIX removes it after this read. Normal cleanup removes
|
||||
@@ -3716,7 +3718,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonObject callRelationshipCoverage(
|
||||
private JsonObject callRelationshipCoverage(
|
||||
CallBoundaryObservation calls, Integer missingCallers) {
|
||||
JsonObject coverage = completeResolvedCallCoverage();
|
||||
Integer omitted = calls == null ? null : calls.unrepresentedCalleeCalls();
|
||||
@@ -3733,7 +3735,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
if (calls == null && missingCallers == null) directions.add("callers");
|
||||
directions.add("callees");
|
||||
coverage.add("limited_directions", directions);
|
||||
return coverage;
|
||||
return qualifyProjectCallCoverage(coverage, calls, missingCallers);
|
||||
}
|
||||
JsonArray limitations = new JsonArray();
|
||||
if (callerLimitation != null) limitations.add(callerLimitation);
|
||||
@@ -3747,6 +3749,35 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
coverage.add("limitations", limitations);
|
||||
coverage.add("limited_directions", directions);
|
||||
}
|
||||
return qualifyProjectCallCoverage(coverage, calls, missingCallers);
|
||||
}
|
||||
|
||||
private JsonObject qualifyProjectCallCoverage(
|
||||
JsonObject coverage, CallBoundaryObservation calls, Integer missingCallers) {
|
||||
if (!existingProject) return coverage;
|
||||
String reason = "The selected project's prior analysis completeness is unknown; " +
|
||||
"the recorded call relationships may omit undiscovered callers or callees.";
|
||||
String status = coverage.get("status").getAsString();
|
||||
// A single-direction read with known omissions is still partial; an
|
||||
// unknown project history adds a limitation rather than erasing them.
|
||||
if (status.equals("partial") && (calls == null || missingCallers == null)) {
|
||||
coverage.getAsJsonArray("limitations").add(reason);
|
||||
return coverage;
|
||||
}
|
||||
if (status.equals("unknown")) reason += " " + coverage.get("reason").getAsString();
|
||||
if (status.equals("partial")) {
|
||||
for (JsonElement limitation : coverage.getAsJsonArray("limitations"))
|
||||
reason += " " + limitation.getAsString();
|
||||
}
|
||||
JsonArray directions = new JsonArray();
|
||||
if (missingCallers != null || (calls == null && missingCallers == null))
|
||||
directions.add("callers");
|
||||
if (calls != null || (calls == null && missingCallers == null))
|
||||
directions.add("callees");
|
||||
coverage.addProperty("status", "unknown");
|
||||
coverage.addProperty("reason", reason);
|
||||
coverage.remove("limitations");
|
||||
coverage.add("limited_directions", directions);
|
||||
return coverage;
|
||||
}
|
||||
|
||||
|
||||
@@ -1558,6 +1558,11 @@ For supported header-selected MIPS ELF targets, the profile also binds the
|
||||
selected target's ELF metadata; it does not claim a fresh-import MIPS lane was
|
||||
used for the saved project.
|
||||
A matching recorded import digest does not claim that the database is unedited.
|
||||
Caller and callee coverage therefore remains unknown. A single-direction query
|
||||
with known omissions remains partial and also reports the unknown project history.
|
||||
Observed call edges are still available: a path to an observed caller or callee
|
||||
can be reached, but a missing path is unknown when the project's prior analysis
|
||||
cannot establish completeness.
|
||||
Saved REA seed/analyzer reports remain historical facts in retained source Evidence;
|
||||
they are not interpreted as overrides applied by this session.
|
||||
Project content participates in snapshot identity, so different project states
|
||||
|
||||
@@ -45,6 +45,7 @@ prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
|
||||
| Ghidra lane | Supported runner/target | Additional local tools |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:project-coverage` | POSIX host; selected-project call coverage, real MCP, retained Evidence and an unexecuted host fixture | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:seeds` | POSIX; raw x86 fixture; seed ordering, BSS labels, analyzer readback, not-applicable and unknown analyzer names | Ghidra 12.1.4 and full JDK 21 |
|
||||
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||
| `npm run verify:ghidra:riscv` | Linux/macOS x64/arm64 host; source-owned RV32/RV64 double-float and RV32 ilp32 ELF through CLI and MCP | RISC-V Clang, LLD, LLVM readelf and objdump, Ghidra 12.1.4, and full JDK 21 |
|
||||
|
||||
@@ -207,6 +207,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:project-coverage": "npm run build:cached && node scripts/verify-real-ghidra-project-coverage.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",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Client, Transport } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
/** Share an in-flight SDK close, including one started during failed initialization. */
|
||||
export class VerifierStdioTransport extends StdioClientTransport {}
|
||||
|
||||
type VerifierConnection = {
|
||||
client: Client;
|
||||
transport: Transport;
|
||||
definitions: Map<
|
||||
string,
|
||||
Awaited<ReturnType<Client["listTools"]>>["tools"][number]
|
||||
>;
|
||||
};
|
||||
|
||||
/** Own MCP transports from connection through discovery, use, and cleanup retry. */
|
||||
export function createVerifierMcpOwner(): {
|
||||
open(client: Client, transport: Transport): Promise<VerifierConnection>;
|
||||
closeConnection(connection: VerifierConnection): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import {
|
||||
closeVerifierResources,
|
||||
withVerifierCleanup,
|
||||
} from "./verifier-cleanup.mjs";
|
||||
|
||||
/** Share an in-flight SDK close, including one started during failed initialization. */
|
||||
export class VerifierStdioTransport extends StdioClientTransport {
|
||||
#closing;
|
||||
|
||||
close() {
|
||||
if (this.#closing !== undefined) return this.#closing;
|
||||
this.#closing = super.close().catch((error) => {
|
||||
this.#closing = undefined;
|
||||
throw error;
|
||||
});
|
||||
return this.#closing;
|
||||
}
|
||||
}
|
||||
|
||||
/** Own MCP transports from connection through discovery, use, and cleanup retry. */
|
||||
export function createVerifierMcpOwner() {
|
||||
const owned = new Set();
|
||||
const closeConnection = async (connection) => {
|
||||
await closeVerifierResources([
|
||||
() => connection.client.close(),
|
||||
() => connection.transport.close(),
|
||||
]);
|
||||
owned.delete(connection);
|
||||
};
|
||||
return {
|
||||
async open(client, transport) {
|
||||
const connection = { client, transport, definitions: new Map() };
|
||||
owned.add(connection);
|
||||
try {
|
||||
await client.connect(transport);
|
||||
connection.definitions = new Map(
|
||||
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
|
||||
);
|
||||
return connection;
|
||||
} catch (cause) {
|
||||
return withVerifierCleanup(() => {
|
||||
throw cause;
|
||||
}, [() => closeConnection(connection)]);
|
||||
}
|
||||
},
|
||||
closeConnection,
|
||||
close() {
|
||||
return closeVerifierResources(
|
||||
[...owned].map((connection) => () => closeConnection(connection)),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../dist/process/OwnedCommand.js";
|
||||
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { createAnalysisProfile } from "../dist/domain/analysisProfile.js";
|
||||
import { createEvidenceBundle } from "../dist/domain/evidenceBundle.js";
|
||||
import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs";
|
||||
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
||||
import {
|
||||
createVerifierMcpOwner,
|
||||
VerifierStdioTransport,
|
||||
} from "./lib/verifier-mcp-owner.mjs";
|
||||
import {
|
||||
requireMcpOutput,
|
||||
requireMcpToolError,
|
||||
} from "./lib/mcp-verifier-results.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
export async function verifyProjectCoverage(run) {
|
||||
const installDir = process.env.GHIDRA_INSTALL_DIR;
|
||||
if (installDir === undefined || !isAbsolute(installDir))
|
||||
throw new Error(
|
||||
"Set GHIDRA_INSTALL_DIR to an absolute Ghidra installation path.",
|
||||
);
|
||||
const installation = await inspectVerifierGhidraInstallation({
|
||||
environment: process.env,
|
||||
installDir,
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
: { javaHome: process.env.JAVA_HOME }),
|
||||
});
|
||||
if (installation.status !== "available")
|
||||
throw new Error(`Ghidra is unavailable: ${JSON.stringify(installation)}`);
|
||||
if (process.platform === "win32")
|
||||
throw new Error(
|
||||
"REA selected Ghidra projects are supported on POSIX hosts.",
|
||||
);
|
||||
const scriptRoot = fileURLToPath(
|
||||
new URL("../tests/conformance/ghidra/", import.meta.url),
|
||||
);
|
||||
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
|
||||
const workspace = await mkdtemp(
|
||||
join(tmpdir(), "rea-ghidra-project-coverage-"),
|
||||
);
|
||||
const connections = createVerifierMcpOwner();
|
||||
const target = join(workspace, "fixture");
|
||||
const projects = join(workspace, "projects");
|
||||
const env = {
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
||||
JAVA_HOME: installation.javaHome,
|
||||
GHIDRA_INSTALL_DIR: installation.installDir,
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: "compact",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_PROCESS_RUN_ID: run.run_id,
|
||||
};
|
||||
const hash = (data) => createHash("sha256").update(data).digest("hex");
|
||||
async function manifest(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory()
|
||||
? manifest(path)
|
||||
: entry.isFile()
|
||||
? [[path, hash(await readFile(path))]]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
return files.flat().sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}
|
||||
async function connect(environment) {
|
||||
const client = new Client({
|
||||
name: "selected-project-coverage-verifier",
|
||||
version: "1",
|
||||
});
|
||||
const transport = new VerifierStdioTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
return connections.open(client, transport);
|
||||
}
|
||||
async function call(connection, name, args = {}) {
|
||||
const reply = await connection.client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: 180000, toolDefinition: connection.definitions.get(name) },
|
||||
);
|
||||
return requireMcpOutput(reply, name);
|
||||
}
|
||||
let commandCleanupIncomplete = false;
|
||||
const result = await withVerifierCleanup(async () => {
|
||||
await mkdir(projects);
|
||||
await exec(
|
||||
// The invoking developer selects the compiler; execFile does not use a shell.
|
||||
process.env.REA_CC ?? "cc",
|
||||
[
|
||||
"-O0",
|
||||
"-g",
|
||||
"-fno-inline",
|
||||
"-fno-optimize-sibling-calls",
|
||||
join(scriptRoot, "selected-project-coverage.c"),
|
||||
"-o",
|
||||
target,
|
||||
],
|
||||
{ env, timeout: 120000 },
|
||||
);
|
||||
for (const name of ["partial", "analyzed"]) {
|
||||
const args = [projects, name, "-import", target];
|
||||
if (name === "partial")
|
||||
args.push(
|
||||
"-noanalysis",
|
||||
"-scriptPath",
|
||||
scriptRoot,
|
||||
"-postScript",
|
||||
"ReaSelectedProjectSeeds.java",
|
||||
);
|
||||
let result;
|
||||
try {
|
||||
result = await runOwnedCommand(
|
||||
{
|
||||
command: installation.analyzeHeadlessPath,
|
||||
arguments: args,
|
||||
env,
|
||||
runId: run.run_id,
|
||||
expectedCommand: null,
|
||||
},
|
||||
{ timeoutMs: 180000, diagnosticBytes: 16 * 1024 * 1024 },
|
||||
);
|
||||
} catch (cause) {
|
||||
commandCleanupIncomplete =
|
||||
cause instanceof OwnedCommandFailure &&
|
||||
cause.cleanupOwner !== undefined;
|
||||
throw cause;
|
||||
}
|
||||
if (name === "partial")
|
||||
for (const seed of [
|
||||
"rea_project_leaf",
|
||||
"rea_project_caller",
|
||||
"rea_project_other",
|
||||
])
|
||||
assert.match(
|
||||
result.stdout.text,
|
||||
new RegExp(`REA_PROJECT_SEED ${seed} `),
|
||||
);
|
||||
}
|
||||
const before = {
|
||||
target: hash(await readFile(target)),
|
||||
projects: await manifest(projects),
|
||||
};
|
||||
const report = [];
|
||||
for (const mode of ["fresh", "partial", "analyzed"]) {
|
||||
const selected = mode !== "fresh";
|
||||
const connection = await connect({
|
||||
...env,
|
||||
...(selected
|
||||
? {
|
||||
REA_GHIDRA_PROJECT_PATH: join(projects, `${mode}.gpr`),
|
||||
REA_GHIDRA_PROGRAM_NAME: "fixture",
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const bundlePath = join(workspace, `${mode}.json`);
|
||||
const paths = [];
|
||||
await withVerifierCleanup(async () => {
|
||||
await call(connection, "open_binary", {
|
||||
path: target,
|
||||
provider_id: "ghidra",
|
||||
});
|
||||
const inventory = (
|
||||
await call(connection, "list_procedures", {
|
||||
detail: "complete",
|
||||
})
|
||||
).normalized_result;
|
||||
const entry = (name) => {
|
||||
const matches = inventory.filter(
|
||||
(item) => item.value === name || item.value === `_${name}`,
|
||||
);
|
||||
assert.equal(matches.length, 1, `Expected one entry for ${name}`);
|
||||
return matches[0].address;
|
||||
};
|
||||
const leaf = entry("rea_project_leaf"),
|
||||
caller = entry("rea_project_caller"),
|
||||
other = entry("rea_project_other");
|
||||
const direct = await call(connection, "procedure_callees", {
|
||||
procedure: caller,
|
||||
});
|
||||
assert.equal(
|
||||
direct.relationship_coverage.status,
|
||||
selected ? "unknown" : "complete",
|
||||
);
|
||||
assert.equal(
|
||||
direct.normalized_result.includes(leaf),
|
||||
mode !== "partial",
|
||||
);
|
||||
assert.equal(
|
||||
direct.analysis_profile.parameters
|
||||
.selected_project_relationship_coverage,
|
||||
selected ? "prior-analysis-unknown-v1" : undefined,
|
||||
);
|
||||
for (const [start, goal, direction] of [
|
||||
[caller, leaf, "forward"],
|
||||
[leaf, caller, "backward"],
|
||||
[other, caller, "backward"],
|
||||
]) {
|
||||
const evidence = await call(connection, "trace_call_path", {
|
||||
start,
|
||||
goal,
|
||||
direction,
|
||||
});
|
||||
paths.push(evidence);
|
||||
}
|
||||
assert.equal(
|
||||
paths[0].normalized_result.goal_status,
|
||||
mode === "partial" ? "unknown" : "reached",
|
||||
);
|
||||
assert.equal(
|
||||
paths[1].normalized_result.goal_status,
|
||||
mode === "partial" ? "unknown" : "reached",
|
||||
);
|
||||
assert.equal(
|
||||
paths[2].normalized_result.goal_status,
|
||||
selected ? "unknown" : "not_reached",
|
||||
);
|
||||
if (selected)
|
||||
for (const evidence of paths)
|
||||
assert.ok(
|
||||
Object.values(
|
||||
evidence.normalized_result.relationship_coverage,
|
||||
).some(
|
||||
(coverage) =>
|
||||
coverage.status === "unknown" &&
|
||||
/prior analysis completeness is unknown/u.test(
|
||||
coverage.reason,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mode === "analyzed") {
|
||||
const dossier = await call(connection, "analyze_function", {
|
||||
procedure: entry("rea_project_callback"),
|
||||
detail: "complete",
|
||||
});
|
||||
assert.equal(dossier.relationship_coverage.status, "unknown");
|
||||
assert.match(
|
||||
dossier.relationship_coverage.reason,
|
||||
/decoded call\(s\) without destinations/u,
|
||||
);
|
||||
assert.match(
|
||||
dossier.relationship_coverage.reason,
|
||||
/prior analysis completeness is unknown/u,
|
||||
);
|
||||
const callbackPath = await call(connection, "trace_call_path", {
|
||||
start: entry("rea_project_callback"),
|
||||
goal: leaf,
|
||||
direction: "forward",
|
||||
});
|
||||
const coverages = Object.values(
|
||||
callbackPath.normalized_result.relationship_coverage,
|
||||
);
|
||||
assert.ok(
|
||||
coverages.some(
|
||||
(coverage) =>
|
||||
coverage.status === "partial" &&
|
||||
coverage.limitations.some((text) =>
|
||||
/decoded call\(s\) without destinations/u.test(text),
|
||||
) &&
|
||||
coverage.limitations.some((text) =>
|
||||
/prior analysis completeness is unknown/u.test(text),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
await call(connection, "export_evidence_bundle", { path: bundlePath });
|
||||
if (mode === "partial") {
|
||||
const snapshotPath = join(workspace, "partial-snapshot.json");
|
||||
await call(connection, "close_binary", {
|
||||
snapshot_path: snapshotPath,
|
||||
});
|
||||
const snapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
|
||||
assert.equal(
|
||||
snapshot.binding.analysis_profile.parameters
|
||||
.selected_project_relationship_coverage,
|
||||
"prior-analysis-unknown-v1",
|
||||
);
|
||||
const profile = snapshot.binding.analysis_profile;
|
||||
const oldParameters = { ...profile.parameters };
|
||||
delete oldParameters.selected_project_relationship_coverage;
|
||||
const stalePath = join(workspace, "partial-old-profile.json");
|
||||
await writeFile(
|
||||
stalePath,
|
||||
JSON.stringify({
|
||||
...snapshot,
|
||||
binding: {
|
||||
...snapshot.binding,
|
||||
analysis_profile: createAnalysisProfile(
|
||||
profile.provider,
|
||||
oldParameters,
|
||||
),
|
||||
},
|
||||
entries: [],
|
||||
workflow_entries: [],
|
||||
evidence_bundle: createEvidenceBundle([]),
|
||||
}),
|
||||
);
|
||||
const staleBefore = await readFile(stalePath);
|
||||
const refused = await connection.client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: {
|
||||
path: target,
|
||||
provider_id: "ghidra",
|
||||
snapshot_path: stalePath,
|
||||
},
|
||||
});
|
||||
const error = requireMcpToolError(refused);
|
||||
assert.equal(error.code, "evidence_integrity_mismatch");
|
||||
assert.deepEqual(error.details.binding_mismatch.profile_digest, {
|
||||
snapshot: createAnalysisProfile(profile.provider, oldParameters)
|
||||
.digest,
|
||||
selected: profile.digest,
|
||||
});
|
||||
assert.deepEqual(await readFile(stalePath), staleBefore);
|
||||
await call(connection, "open_binary", {
|
||||
path: target,
|
||||
provider_id: "ghidra",
|
||||
snapshot_path: snapshotPath,
|
||||
});
|
||||
const replay = await call(connection, "trace_call_path", {
|
||||
start: caller,
|
||||
goal: leaf,
|
||||
direction: "forward",
|
||||
});
|
||||
assert.deepEqual(
|
||||
replay.normalized_result,
|
||||
paths[0].normalized_result,
|
||||
);
|
||||
await call(connection, "close_binary");
|
||||
} else await call(connection, "close_binary");
|
||||
}, [() => connections.closeConnection(connection)]);
|
||||
const offline = await connect(env);
|
||||
await withVerifierCleanup(async () => {
|
||||
await call(offline, "import_evidence_bundle", { path: bundlePath });
|
||||
const records = (
|
||||
await call(offline, "get_evidence_bundle", { detail: "complete" })
|
||||
).result.records;
|
||||
for (const evidence of paths) {
|
||||
const saved = records.find(
|
||||
(record) => record.evidence_id === evidence.evidence_id,
|
||||
);
|
||||
assert.ok(saved);
|
||||
assert.deepEqual(saved.normalized_result, evidence.normalized_result);
|
||||
assert.deepEqual(saved.limitations, evidence.limitations);
|
||||
}
|
||||
}, [() => connections.closeConnection(offline)]);
|
||||
report.push({
|
||||
mode,
|
||||
statuses: paths.map(
|
||||
(evidence) => evidence.normalized_result.goal_status,
|
||||
),
|
||||
});
|
||||
}
|
||||
assert.deepEqual(
|
||||
{
|
||||
target: hash(await readFile(target)),
|
||||
projects: await manifest(projects),
|
||||
},
|
||||
before,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
provider: installation.providerVersion,
|
||||
results: report,
|
||||
};
|
||||
}, [
|
||||
async () => {
|
||||
await connections.close();
|
||||
if (!commandCleanupIncomplete)
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
},
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] !== undefined &&
|
||||
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
const run = createVerifierRun();
|
||||
const report = await verifyProjectCoverage(run);
|
||||
await completeVerifierRun(run);
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { verifyProjectCoverage } from "./verify-real-ghidra-project-coverage.mjs";
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -320,6 +322,10 @@ try {
|
||||
const customPath = process.env.GHIDRA_TARGET_PATH;
|
||||
const custom =
|
||||
customPath === undefined ? null : await verifyTarget(customPath, "custom");
|
||||
const projectCoverage =
|
||||
!crossFormat && !aarch64JumpTableOnly && process.platform !== "win32"
|
||||
? await verifyProjectCoverage(verifierRun)
|
||||
: null;
|
||||
report = {
|
||||
ok: true,
|
||||
provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION },
|
||||
@@ -340,6 +346,7 @@ try {
|
||||
? (crossResults[0]?.native_api_cli ?? null)
|
||||
: jumpTable.native_api_cli,
|
||||
native_value_e2e: debug.native_values,
|
||||
selected_project_coverage: projectCoverage,
|
||||
relocation_symbols: relocationSymbols,
|
||||
custom_target: custom === null ? null : summary(custom),
|
||||
cleanup: "complete",
|
||||
|
||||
@@ -485,6 +485,7 @@ export const existingGhidraAnalysisProfile = (
|
||||
...(mipsElf === undefined ? {} : { mips_elf: mipsElf }),
|
||||
import_mode: "existing-project-private-copy-v1",
|
||||
...sharedAnalysisSemantics,
|
||||
selected_project_relationship_coverage: "prior-analysis-unknown-v1",
|
||||
annotation_policy: "read-only-existing-project-v1",
|
||||
existing_project: project,
|
||||
original_analysis: { completeness: "unknown", settings: "unknown" },
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client, type Transport } from "@modelcontextprotocol/client";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
createVerifierMcpOwner,
|
||||
VerifierStdioTransport,
|
||||
} from "../../../scripts/lib/verifier-mcp-owner.mjs";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it.each(["initialize", "discovery"])(
|
||||
"closes the real MCP child when %s fails before ownership transfers",
|
||||
async (mode) => {
|
||||
const workspace = await createTestTempDirectory("rea-verifier-mcp-owner-");
|
||||
const marker = join(workspace, "exit.txt");
|
||||
const client = new Client({ name: "verifier-owner-test", version: "1" });
|
||||
const transport = new VerifierStdioTransport({
|
||||
command: process.execPath,
|
||||
args: [
|
||||
fileURLToPath(
|
||||
new URL("../../fixtures/verifier-mcp-failure.mjs", import.meta.url),
|
||||
),
|
||||
],
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
REA_TEST_EXIT_MARKER: marker,
|
||||
REA_TEST_FAILURE_MODE: mode,
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
const owner = createVerifierMcpOwner();
|
||||
try {
|
||||
await expect(owner.open(client, transport)).rejects.toThrow();
|
||||
expect(await readFile(marker, "utf8")).toBe("exited");
|
||||
} finally {
|
||||
await owner.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains an owner after failed cleanup and retries every close", async () => {
|
||||
const failure = new Error("discovery failed");
|
||||
const cleanupFailure = new Error("client close failed");
|
||||
let clientCloses = 0;
|
||||
let transportCloses = 0;
|
||||
const client = {
|
||||
connect: async () => {},
|
||||
listTools: async () => {
|
||||
throw failure;
|
||||
},
|
||||
close: async () => {
|
||||
if (++clientCloses === 1) throw cleanupFailure;
|
||||
},
|
||||
} as unknown as Client;
|
||||
const transport = {
|
||||
close: async () => {
|
||||
transportCloses++;
|
||||
},
|
||||
} as unknown as Transport;
|
||||
const owner = createVerifierMcpOwner();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await owner.open(client, transport);
|
||||
} catch (cause) {
|
||||
thrown = cause;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(AggregateError);
|
||||
expect((thrown as AggregateError).cause).toBe(failure);
|
||||
expect(transportCloses).toBe(1);
|
||||
await owner.close();
|
||||
expect(clientCloses).toBe(2);
|
||||
expect(transportCloses).toBe(2);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import ghidra.app.script.GhidraScript;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.address.AddressSet;
|
||||
import ghidra.program.model.listing.Function;
|
||||
import ghidra.program.model.symbol.SourceType;
|
||||
import ghidra.program.model.symbol.Symbol;
|
||||
import java.util.List;
|
||||
|
||||
// Create addressable entries without decoding their instructions or analyzing calls.
|
||||
public class ReaSelectedProjectSeeds extends GhidraScript {
|
||||
@Override public void run() throws Exception {
|
||||
for (String name : List.of("rea_project_leaf", "rea_project_caller", "rea_project_other")) {
|
||||
List<Symbol> symbols = getSymbols(name, null);
|
||||
if (symbols.isEmpty()) symbols = getSymbols("_" + name, null);
|
||||
if (symbols.size() != 1) throw new IllegalStateException("Expected one symbol for " + name);
|
||||
Address entry = symbols.get(0).getAddress();
|
||||
Function function = getFunctionAt(entry);
|
||||
if (function == null)
|
||||
function = currentProgram.getFunctionManager().createFunction(
|
||||
symbols.get(0).getName(), entry, new AddressSet(entry, entry), SourceType.USER_DEFINED);
|
||||
if (currentProgram.getListing().getInstructions(function.getBody(), true).hasNext())
|
||||
throw new IllegalStateException("Expected an unanalyzed function: " + name);
|
||||
println("REA_PROJECT_SEED " + name + " " + entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
volatile int rea_project_global = 7;
|
||||
__attribute__((noinline, used)) int rea_project_leaf(int x) {
|
||||
return x + rea_project_global;
|
||||
}
|
||||
__attribute__((noinline, used)) int rea_project_caller(int x) {
|
||||
return rea_project_leaf(x);
|
||||
}
|
||||
__attribute__((noinline, used)) int rea_project_other(int x) {
|
||||
return x - rea_project_global;
|
||||
}
|
||||
__attribute__((noinline, used)) int rea_project_callback(int (*fn)(int), int x) {
|
||||
return fn(x) + rea_project_global;
|
||||
}
|
||||
int main(void) { return rea_project_caller(rea_project_global); }
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const marker = process.env.REA_TEST_EXIT_MARKER;
|
||||
const mode = process.env.REA_TEST_FAILURE_MODE;
|
||||
if (!marker || !mode) throw new Error("Missing verifier fixture configuration");
|
||||
const keepAlive = setInterval(() => {}, 1000);
|
||||
// The test harness selects its own temporary exit marker.
|
||||
process.on("exit", () => writeFileSync(marker, "exited"));
|
||||
process.stdin.on("end", () => clearInterval(keepAlive));
|
||||
const input = createInterface({ input: process.stdin });
|
||||
input.on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
if (request.id === undefined) return;
|
||||
if (request.method === "initialize" && mode !== "initialize") {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: request.id,
|
||||
result: {
|
||||
protocolVersion: request.params.protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "verifier-failure-fixture", version: "1" },
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: request.id,
|
||||
error: { code: -32603, message: `intentional ${mode} failure` },
|
||||
})}\n`,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user