mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5b62fe820 | ||
|
|
e5af91e689 | ||
|
|
ac30fbd658 | ||
|
|
5d21bddb18 | ||
|
|
5d0ba95b62 | ||
|
|
e61a0b093a | ||
|
|
2e2f981ccf | ||
|
|
f8bb792f15 | ||
|
|
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -310,8 +310,8 @@ entry it does not read. The module reads the central directory itself, as REA
|
||||
does, and shows `zipfile` only the records it lists, reading each entry in
|
||||
bounded chunks no further than a byte past its declared size. A JSON file
|
||||
holding a number JavaScript cannot read exactly, such as an integer above 2^53,
|
||||
or nested more than 500 levels deep, gets an `error` instead of a view, as in
|
||||
REA, while text that is not one JSON
|
||||
or whose retained value nests more than 500 levels deep, gets an `error` instead of a view, as in
|
||||
REA. Duplicate keys keep their last value before the depth check. Text that is not one JSON
|
||||
document, such as JSON Lines, stays text. Format detection follows REA's
|
||||
rules. Files larger than 64 MiB get an `error` instead of a view
|
||||
and are not read. XML is read with Python's `expat` twice: with namespace
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -266,12 +266,10 @@ export const registerBinaryDiagnosticsCommands = (
|
||||
// "0x10" or "1e2".
|
||||
blockSize: cliDecimalField(
|
||||
inspectByteEntropyInputSchema.shape.block_size,
|
||||
).describe(
|
||||
"Power-of-two block size from 256 to 65536 bytes (default 4096)",
|
||||
),
|
||||
).describe("Power-of-two block size from 256 to 65536 bytes"),
|
||||
offset: cliDecimalField(
|
||||
inspectByteEntropyInputSchema.shape.offset,
|
||||
).describe("Window start as a decimal file byte offset (default 0)"),
|
||||
).describe("Window start as a decimal file byte offset"),
|
||||
length: cliDecimalField(
|
||||
inspectByteEntropyInputSchema.shape.length,
|
||||
).describe("Window length in bytes; omit to extend through EOF"),
|
||||
@@ -429,16 +427,16 @@ export const registerBinaryDiagnosticsCommands = (
|
||||
options: z.object({
|
||||
maxFileBytes: cliDecimalField(
|
||||
inspectDevicetreeInputSchema.shape.max_file_bytes,
|
||||
).describe("Maximum FDT file size in bytes (default 4194304)"),
|
||||
).describe("Maximum FDT file size in bytes"),
|
||||
maxNodes: cliDecimalField(
|
||||
inspectDevicetreeInputSchema.shape.max_nodes,
|
||||
).describe("Maximum number of device-tree nodes (default 8192)"),
|
||||
).describe("Maximum number of device-tree nodes"),
|
||||
maxProperties: cliDecimalField(
|
||||
inspectDevicetreeInputSchema.shape.max_properties,
|
||||
).describe("Maximum number of device-tree properties (default 32768)"),
|
||||
).describe("Maximum number of device-tree properties"),
|
||||
maxDepth: cliDecimalField(
|
||||
inspectDevicetreeInputSchema.shape.max_depth,
|
||||
).describe("Maximum device-tree nesting depth (default 64)"),
|
||||
).describe("Maximum device-tree nesting depth"),
|
||||
...analysisEvidenceDeliveryOptions,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("CLI JSON path normalization", () => {
|
||||
expect.objectContaining({
|
||||
path: ["executable_path"],
|
||||
code: "invalid_format",
|
||||
message: expect.stringContaining("absolute local filesystem path"),
|
||||
format: "regex",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ const captureInputShape = z.strictObject({
|
||||
.int()
|
||||
.nonnegative()
|
||||
.default(0)
|
||||
.describe("Skip this many matching packets after filtering; default 0"),
|
||||
.describe("Skip this many matching packets after filtering"),
|
||||
match_limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -5,9 +5,7 @@ export const inspectPacketFieldsInputSchema = z.strictObject({
|
||||
kind: z
|
||||
.enum(["protocols", "fields"])
|
||||
.default("protocols")
|
||||
.describe(
|
||||
"Return protocol registrations or field definitions; defaults to protocols",
|
||||
),
|
||||
.describe("Return protocol registrations or field definitions"),
|
||||
protocol: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -30,7 +28,7 @@ export const inspectPacketFieldsInputSchema = z.strictObject({
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(0)
|
||||
.describe("Number of matching records to skip; defaults to 0"),
|
||||
.describe("Number of matching records to skip"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PYTHON_JSON_READER } from "./appExportPythonJson.js";
|
||||
import { PYTHON_XML_READER } from "./appExportPythonXml.js";
|
||||
import { PYTHON_ZIP_HELPERS } from "./appExportPythonZip.js";
|
||||
|
||||
@@ -12,6 +13,7 @@ export const PYTHON_READY_CHECK = `
|
||||
|
||||
export const PYTHON_IMPORTS = `from __future__ import annotations
|
||||
|
||||
from array import array
|
||||
import contextlib
|
||||
import copy
|
||||
import hashlib
|
||||
@@ -57,11 +59,6 @@ MAX_PARSED_BYTES = 4 * 1024 * 1024
|
||||
PARSED_TOO_LARGE = "larger than the 4 MiB limit for JSON and key file views"
|
||||
MAX_JSON_VIEW_DEPTH = 500
|
||||
JSON_TOO_DEEP = f"nested deeper than the {MAX_JSON_VIEW_DEPTH} levels a JSON view keeps"
|
||||
#: A JSON token after any whitespace, by group: an opening, a closing, a comma, a colon, a string's quote, or a number
|
||||
#: or literal. Strings are read by json's scanner, since a pattern repeating per escape holds memory for each.
|
||||
JSON_TOKEN = re.compile(r'[ \t\n\r]*(?:([\[{])|([\]}])|(,)|(:)|(")|(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?'
|
||||
r"|true|false|null))")
|
||||
JSON_SPACE = re.compile(r"[ \t\n\r]*")
|
||||
|
||||
|
||||
def _respelled(text: str, home: Path) -> str:
|
||||
@@ -254,143 +251,7 @@ def _keyfile(text: str) -> dict:
|
||||
|
||||
${PYTHON_XML_READER}
|
||||
|
||||
def _not_json(constant: str):
|
||||
raise ValueError(f"{constant} is not JSON")
|
||||
|
||||
|
||||
class _Inexact(Exception):
|
||||
"""A JSON number that REA's state view refuses, since reading it would change it."""
|
||||
|
||||
|
||||
def _digits(text: str) -> str:
|
||||
return re.split(r"[eE]", text.lstrip("-"))[0].replace(".", "").strip("0") or "0"
|
||||
|
||||
|
||||
def _js_string(value: float) -> str:
|
||||
"""How JavaScript's String() writes a finite double: an integer below 1e21 in full, any other in shortest form."""
|
||||
return str(int(value)) if value.is_integer() and abs(value) < 1e21 else repr(value)
|
||||
|
||||
|
||||
def _safe_number(text: str) -> bool:
|
||||
"""lossless-json's isSafeNumber: an integer spelling within 2^53, or a float spelling that keeps its digits."""
|
||||
if re.fullmatch(r"-?[0-9]+", text):
|
||||
return len(text.lstrip("-")) <= 16 and abs(int(text)) <= MAX_SAFE_INTEGER
|
||||
value = float(text)
|
||||
if not math.isfinite(value):
|
||||
return False
|
||||
written = _js_string(value)
|
||||
return written == text or _digits(written) == _digits(text)
|
||||
|
||||
|
||||
def _exact_number(text: str):
|
||||
"""A JSON number read only when reading it changes nothing, as REA's state view rules: its spelling and its
|
||||
written-back form both safe, and not -0."""
|
||||
value = float(text)
|
||||
if (not _safe_number(text) or not math.isfinite(value) or not _safe_number(_js_string(value))
|
||||
or (value == 0 and math.copysign(1, value) < 0)):
|
||||
raise _Inexact(text)
|
||||
return int(text) if re.fullmatch(r"-?[0-9]+", text) else value
|
||||
|
||||
|
||||
_INEXACT = object()
|
||||
_LEVEL_END = object()
|
||||
|
||||
|
||||
def _number_or_mark(spelling: str):
|
||||
"""A JSON number read exactly, or a mark for one REA's state view refuses."""
|
||||
try:
|
||||
return _exact_number(spelling)
|
||||
except _Inexact:
|
||||
return _INEXACT
|
||||
|
||||
|
||||
def _holds_inexact(value) -> bool:
|
||||
"""Whether a parsed JSON value still holds a marked number once duplicate keys kept their last value; one
|
||||
iterator per nesting level, so no child list is copied."""
|
||||
levels = [iter((value,))]
|
||||
while levels:
|
||||
item = next(levels[-1], _LEVEL_END)
|
||||
if item is _LEVEL_END:
|
||||
levels.pop()
|
||||
elif item is _INEXACT:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
levels.append(iter(item.values()))
|
||||
elif isinstance(item, list):
|
||||
levels.append(iter(item))
|
||||
return False
|
||||
|
||||
|
||||
def _deeper_than(root, limit: int) -> bool:
|
||||
"""Whether a parsed JSON value nests deeper than a limit, as REA checks it: a value's depth is the number of
|
||||
iterators open above it, one per nesting level, so no child list is copied."""
|
||||
levels = [iter((root,))]
|
||||
while levels:
|
||||
item = next(levels[-1], _LEVEL_END)
|
||||
if item is _LEVEL_END:
|
||||
levels.pop()
|
||||
elif len(levels) - 1 > limit:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
levels.append(iter(item.values()))
|
||||
elif isinstance(item, list):
|
||||
levels.append(iter(item))
|
||||
return False
|
||||
|
||||
|
||||
def _is_json(text: str) -> bool:
|
||||
"""Whether text is one JSON value as REA's state view reads it, checked a token at a time without building it or
|
||||
recursing: it holds a byte per open container and one string at a time, so text too deep for Python's parser or
|
||||
too large to parse into a view is still told apart from other text."""
|
||||
containers, expect, empty, at = bytearray(), "value", False, 0
|
||||
while True:
|
||||
token = JSON_TOKEN.match(text, at)
|
||||
if token is None:
|
||||
break
|
||||
kind, at = token.lastindex, token.end()
|
||||
if kind == 5:
|
||||
try:
|
||||
at = json.decoder.scanstring(text, at)[1]
|
||||
except ValueError:
|
||||
return False
|
||||
if kind == 1 and expect == "value":
|
||||
containers.append(ord(text[at - 1]))
|
||||
expect, empty = ("value" if text[at - 1] == "[" else "key"), True
|
||||
# A closing bracket's code is its opening's plus two.
|
||||
elif kind == 2 and (expect == "after" or empty) and containers and containers[-1] == ord(text[at - 1]) - 2:
|
||||
containers.pop()
|
||||
expect, empty = "after", False
|
||||
elif kind == 3 and expect == "after" and containers:
|
||||
expect = "value" if containers[-1] == ord("[") else "key"
|
||||
elif kind == 4 and expect == "colon":
|
||||
expect = "value"
|
||||
elif kind >= 5 and (expect == "value" or (kind == 5 and expect == "key")):
|
||||
expect, empty = ("colon" if expect == "key" else "after"), False
|
||||
else:
|
||||
return False
|
||||
return expect == "after" and not containers and JSON_SPACE.fullmatch(text, at) is not None
|
||||
|
||||
|
||||
def _json_view(text: str, size: int):
|
||||
"""A JSON text's format and view, or why it has none, as REA reads it; None when it is not JSON. Text over the
|
||||
parsed-view limit, or nested past Python's recursion limit, is told apart without building its value."""
|
||||
if size > MAX_PARSED_BYTES:
|
||||
return {"format": "json", "view": None, "error": PARSED_TOO_LARGE} if _is_json(text) else None
|
||||
# The whole text must parse, and duplicate keys settle, before an inexact number makes it JSON with an error.
|
||||
try:
|
||||
view = json.loads(text, parse_constant=_not_json, parse_float=_number_or_mark, parse_int=_number_or_mark)
|
||||
except RecursionError:
|
||||
# Nesting too deep for Python to parse is far deeper than REA's view keeps, once the rest of it is JSON too.
|
||||
return {"format": "json", "view": None, "error": JSON_TOO_DEEP} if _is_json(text) else None
|
||||
except ValueError:
|
||||
return None
|
||||
if _holds_inexact(view):
|
||||
return {"format": "json", "view": None,
|
||||
"error": "a number in it cannot be read exactly, such as an integer above 2^53"}
|
||||
if _deeper_than(view, MAX_JSON_VIEW_DEPTH):
|
||||
return {"format": "json", "view": None, "error": JSON_TOO_DEEP}
|
||||
return {"format": "json", "view": view, "truncated": False}
|
||||
|
||||
${PYTHON_JSON_READER}
|
||||
|
||||
def _text_view(name: str, text: str, size: int) -> dict:
|
||||
"""The format REA's state view detects for decoded text of size bytes, and its view; a JSON or key file over the
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/** JSON parsing and bounded retained-view checks for exported desktop environments. */
|
||||
export const PYTHON_JSON_READER = String.raw`
|
||||
#: A JSON token after any whitespace, by group: an opening, a closing, a comma, a colon, a string's quote, or a number
|
||||
#: or literal. Strings are read by json's scanner, since a pattern repeating per escape holds memory for each.
|
||||
JSON_TOKEN = re.compile(r'[ \t\n\r]*(?:([\[{])|([\]}])|(,)|(:)|(")|(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?'
|
||||
r"|true|false|null))")
|
||||
JSON_SPACE = re.compile(r"[ \t\n\r]*")
|
||||
|
||||
|
||||
class _Inexact(Exception):
|
||||
"""A JSON number that REA's state view refuses, since reading it would change it."""
|
||||
|
||||
|
||||
def _digits(text: str) -> str:
|
||||
return re.split(r"[eE]", text.lstrip("-"))[0].replace(".", "").strip("0") or "0"
|
||||
|
||||
|
||||
def _js_string(value: float) -> str:
|
||||
"""How JavaScript's String() writes a finite double: an integer below 1e21 in full, any other in shortest form."""
|
||||
return str(int(value)) if value.is_integer() and abs(value) < 1e21 else repr(value)
|
||||
|
||||
|
||||
def _safe_number(text: str) -> bool:
|
||||
"""lossless-json's isSafeNumber: an integer spelling within 2^53, or a float spelling that keeps its digits."""
|
||||
if re.fullmatch(r"-?[0-9]+", text):
|
||||
return len(text.lstrip("-")) <= 16 and abs(int(text)) <= MAX_SAFE_INTEGER
|
||||
value = float(text)
|
||||
if not math.isfinite(value):
|
||||
return False
|
||||
written = _js_string(value)
|
||||
return written == text or _digits(written) == _digits(text)
|
||||
|
||||
|
||||
def _exact_number(text: str):
|
||||
"""A JSON number read only when reading it changes nothing, as REA's state view rules: its spelling and its
|
||||
written-back form both safe, and not -0."""
|
||||
value = float(text)
|
||||
if (not _safe_number(text) or not math.isfinite(value) or not _safe_number(_js_string(value))
|
||||
or (value == 0 and math.copysign(1, value) < 0)):
|
||||
raise _Inexact(text)
|
||||
return int(text) if re.fullmatch(r"-?[0-9]+", text) else value
|
||||
|
||||
|
||||
_INEXACT = object()
|
||||
_DEEP = object()
|
||||
_DEEP_INEXACT = object()
|
||||
_LEVEL_END = object()
|
||||
_INVALID_JSON = object()
|
||||
JSON_ATOMS = {"true": True, "false": False, "null": None}
|
||||
|
||||
|
||||
def _number_or_mark(spelling: str):
|
||||
"""A JSON number read exactly, or a mark for one REA's state view refuses."""
|
||||
try:
|
||||
return _exact_number(spelling)
|
||||
except _Inexact:
|
||||
return _INEXACT
|
||||
|
||||
|
||||
def _holds_inexact(value) -> bool:
|
||||
"""Whether a parsed JSON value still holds a marked number once duplicate keys kept their last value; one
|
||||
iterator per nesting level, so no child list is copied."""
|
||||
levels = [iter((value,))]
|
||||
while levels:
|
||||
item = next(levels[-1], _LEVEL_END)
|
||||
if item is _LEVEL_END:
|
||||
levels.pop()
|
||||
elif item is _INEXACT or item is _DEEP_INEXACT:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
levels.append(iter(item.values()))
|
||||
elif isinstance(item, list):
|
||||
levels.append(iter(item))
|
||||
return False
|
||||
|
||||
|
||||
def _deeper_than(root, limit: int) -> bool:
|
||||
"""Whether a parsed JSON value nests deeper than a limit, as REA checks it: a value's depth is the number of
|
||||
iterators open above it, one per nesting level, so no child list is copied."""
|
||||
levels = [iter((root,))]
|
||||
while levels:
|
||||
item = next(levels[-1], _LEVEL_END)
|
||||
if item is _LEVEL_END:
|
||||
levels.pop()
|
||||
elif item is _DEEP or item is _DEEP_INEXACT:
|
||||
return True
|
||||
elif len(levels) - 1 > limit:
|
||||
return True
|
||||
elif isinstance(item, dict):
|
||||
levels.append(iter(item.values()))
|
||||
elif isinstance(item, list):
|
||||
levels.append(iter(item))
|
||||
return False
|
||||
|
||||
|
||||
def _read_json(text: str, build: bool = True):
|
||||
"""One JSON value, or _INVALID_JSON, read without recursion so duplicate keys settle before depth is checked.
|
||||
Deep containers are summarized rather than built; exact-number marks still follow last-key-wins semantics.
|
||||
With build=False, only syntax is checked: one byte per open container and one decoded string at a time."""
|
||||
containers, expect, empty, at = bytearray(), "value", False, 0
|
||||
values, keys, root = [], [], _INVALID_JSON
|
||||
deep_bad, deep_key_at, bad_keys = bytearray(), array("I"), {}
|
||||
|
||||
def keep(value) -> None:
|
||||
nonlocal root
|
||||
if not values:
|
||||
root = value
|
||||
elif containers[-1] == ord("["):
|
||||
values[-1].append(value)
|
||||
else:
|
||||
values[-1][keys[-1]] = value
|
||||
|
||||
def record_deep(bad: bool) -> None:
|
||||
"""Summarize a finished child; keep only object keys with live inexact values."""
|
||||
depth = len(deep_bad) - 1
|
||||
if containers[-1] == ord("["):
|
||||
deep_bad[-1] |= 2 | bad
|
||||
return
|
||||
marked = bad_keys.get(depth)
|
||||
if bad or marked is not None:
|
||||
key, _ = json.decoder.scanstring(text, deep_key_at[-1])
|
||||
if bad:
|
||||
if marked is None:
|
||||
marked = key
|
||||
elif isinstance(marked, str):
|
||||
if marked != key:
|
||||
marked = {marked, key}
|
||||
else:
|
||||
marked.add(key)
|
||||
else:
|
||||
if isinstance(marked, str):
|
||||
if marked == key:
|
||||
marked = None
|
||||
else:
|
||||
marked.discard(key)
|
||||
if len(marked) == 1:
|
||||
marked = next(iter(marked))
|
||||
if marked is None:
|
||||
bad_keys.pop(depth, None)
|
||||
else:
|
||||
bad_keys[depth] = marked
|
||||
deep_bad[-1] = 2 | (marked is not None)
|
||||
else:
|
||||
deep_bad[-1] |= 2
|
||||
|
||||
while True:
|
||||
token = JSON_TOKEN.match(text, at)
|
||||
if token is None:
|
||||
break
|
||||
kind, at = token.lastindex, token.end()
|
||||
if kind == 5:
|
||||
string_at = at
|
||||
try:
|
||||
value, at = json.decoder.scanstring(text, at)
|
||||
except ValueError:
|
||||
return _INVALID_JSON
|
||||
if kind == 1 and expect == "value":
|
||||
if build:
|
||||
if deep_bad or len(containers) >= MAX_JSON_VIEW_DEPTH:
|
||||
deep_bad.append(0)
|
||||
deep_key_at.append(0)
|
||||
else:
|
||||
value = [] if text[at - 1] == "[" else {}
|
||||
keep(value)
|
||||
values.append(value)
|
||||
keys.append(None)
|
||||
containers.append(ord(text[at - 1]))
|
||||
expect, empty = ("value" if text[at - 1] == "[" else "key"), True
|
||||
# A closing bracket's code is its opening's plus two.
|
||||
elif kind == 2 and (expect == "after" or empty) and containers and containers[-1] == ord(text[at - 1]) - 2:
|
||||
opening = containers.pop()
|
||||
if build:
|
||||
if deep_bad:
|
||||
state = deep_bad.pop()
|
||||
bad = bool(state & 1)
|
||||
deep_key_at.pop()
|
||||
bad_keys.pop(len(deep_bad), None)
|
||||
if deep_bad:
|
||||
record_deep(bad)
|
||||
else:
|
||||
keep((_DEEP_INEXACT if bad else _DEEP) if state & 2 else ([] if opening == ord("[") else {}))
|
||||
else:
|
||||
values.pop()
|
||||
keys.pop()
|
||||
expect, empty = "after", False
|
||||
elif kind == 3 and expect == "after" and containers:
|
||||
expect = "value" if containers[-1] == ord("[") else "key"
|
||||
elif kind == 4 and expect == "colon":
|
||||
expect = "value"
|
||||
elif kind >= 5 and (expect == "value" or (kind == 5 and expect == "key")):
|
||||
if build:
|
||||
if expect == "key":
|
||||
if deep_bad:
|
||||
deep_key_at[-1] = string_at
|
||||
else:
|
||||
keys[-1] = value
|
||||
else:
|
||||
if kind == 6:
|
||||
value = JSON_ATOMS.get(token[6], _INVALID_JSON)
|
||||
if value is _INVALID_JSON:
|
||||
value = _number_or_mark(token[6])
|
||||
if deep_bad:
|
||||
record_deep(value is _INEXACT)
|
||||
else:
|
||||
keep(value)
|
||||
expect, empty = ("colon" if expect == "key" else "after"), False
|
||||
else:
|
||||
return _INVALID_JSON
|
||||
if expect != "after" or containers or JSON_SPACE.fullmatch(text, at) is None:
|
||||
return _INVALID_JSON
|
||||
return root if build else True
|
||||
|
||||
|
||||
def _json_view(text: str, size: int):
|
||||
"""A JSON text's format and view, or why it has none, as REA reads it; None when it is not JSON. Text over the
|
||||
parsed-view limit is told apart without building its value."""
|
||||
if size > MAX_PARSED_BYTES:
|
||||
valid = _read_json(text, build=False) is not _INVALID_JSON
|
||||
return {"format": "json", "view": None, "error": PARSED_TOO_LARGE} if valid else None
|
||||
# The whole text must parse, and duplicate keys settle, before an inexact number makes it JSON with an error.
|
||||
view = _read_json(text)
|
||||
if view is _INVALID_JSON:
|
||||
return None
|
||||
if _holds_inexact(view):
|
||||
return {"format": "json", "view": None,
|
||||
"error": "a number in it cannot be read exactly, such as an integer above 2^53"}
|
||||
if _deeper_than(view, MAX_JSON_VIEW_DEPTH):
|
||||
return {"format": "json", "view": None, "error": JSON_TOO_DEEP}
|
||||
return {"format": "json", "view": view, "truncated": False}
|
||||
|
||||
`;
|
||||
@@ -21,11 +21,20 @@ const PATHS = [
|
||||
];
|
||||
|
||||
it("matches the host path module for absolute local paths", () => {
|
||||
const checked: string[] = [];
|
||||
for (const path of PATHS) {
|
||||
checked.push(path);
|
||||
expect(absoluteLocalPathStringSchema.safeParse(path).success).toBe(
|
||||
isAbsolute(path),
|
||||
);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"/absolute/path",
|
||||
"relative/path",
|
||||
"file:///absolute/path",
|
||||
]),
|
||||
);
|
||||
expect(absoluteLocalPathStringSchema.safeParse("").success).toBe(false);
|
||||
expect(
|
||||
absoluteLocalPathStringSchema.safeParse("/absolute/with\0nul").success,
|
||||
@@ -40,8 +49,18 @@ it("keeps the Windows absolute-path pattern valid in every client mode", () => {
|
||||
() => new RegExp(windowsAbsolutePathPattern.source, flags),
|
||||
flags,
|
||||
).not.toThrow();
|
||||
for (const path of PATHS)
|
||||
const checked: string[] = [];
|
||||
for (const path of PATHS) {
|
||||
checked.push(path);
|
||||
expect(windowsAbsolutePathPattern.test(path), path).toBe(
|
||||
win32.isAbsolute(path),
|
||||
);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"C:\\absolute\\path",
|
||||
"C:relative/path",
|
||||
"/absolute/path",
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -24,9 +24,7 @@ export const inspectSignalDecodersInputSchema = z.strictObject({
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(0)
|
||||
.describe(
|
||||
"Number of matching decoders or help lines to skip; defaults to 0",
|
||||
),
|
||||
.describe("Number of matching decoders or help lines to skip"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -31,7 +31,7 @@ const signalInputShape = z.strictObject({
|
||||
.int()
|
||||
.nonnegative()
|
||||
.default(0)
|
||||
.describe("Skip this many literal LF-separated output lines; default 0"),
|
||||
.describe("Skip this many literal LF-separated output lines"),
|
||||
line_limit: z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -255,11 +255,11 @@ cliTest(
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
expect.objectContaining({
|
||||
path: ["executable_path"],
|
||||
reason: "invalid_format",
|
||||
message: expect.stringContaining("absolute local filesystem path"),
|
||||
},
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
@@ -329,21 +330,84 @@ const pathRequests: readonly [string, Record<string, unknown>][] = [
|
||||
["start_gdb_session", { inferior_tty: nul }],
|
||||
];
|
||||
|
||||
const nulIssuePaths = (
|
||||
value: unknown,
|
||||
path: readonly (string | number)[] = [],
|
||||
): (string | number)[][] => {
|
||||
if (typeof value === "string") return value.includes("\0") ? [[...path]] : [];
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((item, index) =>
|
||||
nulIssuePaths(item, [...path, index]),
|
||||
);
|
||||
if (typeof value === "object" && value !== null)
|
||||
return Object.entries(value).flatMap(([key, item]) =>
|
||||
nulIssuePaths(item, [...path, key]),
|
||||
);
|
||||
return [];
|
||||
};
|
||||
|
||||
it("rejects OS-invalid local inputs through the real advertised MCP contracts", async () => {
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const checked: string[] = [];
|
||||
for (const [name, arguments_] of pathRequests) {
|
||||
const response = await call(name, arguments_);
|
||||
expect(response.isError, name).toBe(true);
|
||||
expect(JSON.stringify(response.content), name).toContain("NUL");
|
||||
const paths = nulIssuePaths(arguments_);
|
||||
expect(paths.length, name).toBeGreaterThan(0);
|
||||
const error = parseMcpToolError(await call(name, arguments_)).error;
|
||||
expect(error, name).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { operation: name },
|
||||
});
|
||||
const issues = z
|
||||
.object({
|
||||
issues: z.array(
|
||||
z.object({
|
||||
path: z.array(z.union([z.string(), z.number()])),
|
||||
reason: z.string(),
|
||||
message: z.string(),
|
||||
expected: z.unknown().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.parse(error.details).issues;
|
||||
expect(issues, name).toEqual(
|
||||
expect.arrayContaining(
|
||||
paths.map((path) =>
|
||||
expect.objectContaining({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
message: expect.stringMatching(/\bNUL\b|null byte|zero byte/i),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
checked.push(name);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"extract_firmware",
|
||||
"inspect_wasm_artifact",
|
||||
"demangle_swift",
|
||||
"capture_electron_scenario",
|
||||
]),
|
||||
);
|
||||
const relative = await call("extract_firmware", {
|
||||
path: "/tmp/firmware.bin",
|
||||
output_directory: "relative-output",
|
||||
});
|
||||
expect(relative.isError).toBe(true);
|
||||
expect(JSON.stringify(relative.content)).toContain(
|
||||
"absolute local filesystem path",
|
||||
);
|
||||
expect(parseMcpToolError(relative).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "extract_firmware",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["output_directory"],
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
const ignoredOptions = [
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createCli } from "../../../src/cli.js";
|
||||
|
||||
type CliInstance = ReturnType<typeof createCli>;
|
||||
|
||||
const serve = async (
|
||||
cli: CliInstance,
|
||||
argv: readonly string[],
|
||||
): Promise<{ readonly stdout: string; readonly exitCode: number }> => {
|
||||
let stdout = "";
|
||||
let exitCode = 0;
|
||||
await cli.serve([...argv], {
|
||||
env: {},
|
||||
exit: (code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
});
|
||||
return { stdout, exitCode };
|
||||
};
|
||||
|
||||
/**
|
||||
* Options whose owning field already declares a schema default. The help
|
||||
* renderer appends that default, so the description must not restate it.
|
||||
*/
|
||||
const singleDefaultOptions: readonly {
|
||||
readonly command: string;
|
||||
readonly options: readonly string[];
|
||||
}[] = [
|
||||
{ command: "inspect-byte-entropy", options: ["--block-size", "--offset"] },
|
||||
{
|
||||
command: "inspect-devicetree",
|
||||
options: [
|
||||
"--max-file-bytes",
|
||||
"--max-nodes",
|
||||
"--max-properties",
|
||||
"--max-depth",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "decode-signal-recording",
|
||||
options: ["--line_offset", "--line_limit"],
|
||||
},
|
||||
{
|
||||
command: "inspect-packet-capture",
|
||||
options: ["--match_offset", "--match_limit"],
|
||||
},
|
||||
{
|
||||
command: "inspect-packet-fields",
|
||||
options: ["--kind", "--offset"],
|
||||
},
|
||||
{ command: "inspect-signal-decoders", options: ["--offset"] },
|
||||
];
|
||||
|
||||
const optionLine = (help: string, option: string): string | undefined => {
|
||||
const line = help
|
||||
.split(/\r?\n/u)
|
||||
.find((candidate) => candidate.trimStart().startsWith(`${option} `));
|
||||
return line?.trim();
|
||||
};
|
||||
|
||||
describe("CLI help default annotations", () => {
|
||||
it("states each schema-owned option default exactly once", async () => {
|
||||
const cli = createCli({});
|
||||
const repeated: string[] = [];
|
||||
for (const { command, options } of singleDefaultOptions) {
|
||||
const { stdout, exitCode } = await serve(cli, [command, "--help"]);
|
||||
expect(exitCode, command).toBe(0);
|
||||
for (const option of options) {
|
||||
const line = optionLine(stdout, option);
|
||||
if (line === undefined) {
|
||||
repeated.push(`${command} ${option}: option line missing`);
|
||||
continue;
|
||||
}
|
||||
const statements = line.match(/default/giu)?.length ?? 0;
|
||||
if (statements !== 1)
|
||||
repeated.push(
|
||||
`${command} ${option}: ${statements} default statements`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(repeated).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ interface PathInputCase {
|
||||
readonly field: string;
|
||||
readonly valid: Readonly<Record<string, unknown>>;
|
||||
readonly invalid: Readonly<Record<string, unknown>>;
|
||||
readonly issuePath?: readonly string[];
|
||||
readonly issuePath: readonly string[];
|
||||
}
|
||||
|
||||
const createMcp = async (
|
||||
@@ -104,7 +104,13 @@ const inputCases = (
|
||||
field: string,
|
||||
valid: Readonly<Record<string, unknown>>,
|
||||
invalid: Readonly<Record<string, unknown>>,
|
||||
): PathInputCase => ({ name, field, valid, invalid });
|
||||
): PathInputCase => ({
|
||||
name,
|
||||
field,
|
||||
valid,
|
||||
invalid,
|
||||
issuePath: field.split("."),
|
||||
});
|
||||
|
||||
return [
|
||||
pathCase("open_binary", "path", { path: absolute }, { path: relative }),
|
||||
@@ -246,8 +252,7 @@ it("advertises full host-absolute path constraints after SDK schema conversion",
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const absolute = resolve("rea-absolute-input");
|
||||
const relative = "relative-input";
|
||||
|
||||
for (const { name, field, valid, invalid } of [
|
||||
const schemaCases = [
|
||||
...inputCases(absolute, relative),
|
||||
{
|
||||
name: "inspect_go_binary",
|
||||
@@ -276,7 +281,9 @@ it("advertises full host-absolute path constraints after SDK schema conversion",
|
||||
right_bundle_path: absolute,
|
||||
},
|
||||
},
|
||||
]) {
|
||||
];
|
||||
const checked: string[] = [];
|
||||
for (const { name, field, valid, invalid } of schemaCases) {
|
||||
const schema = listed.tools.find(
|
||||
(tool) => tool.name === name,
|
||||
)?.inputSchema;
|
||||
@@ -288,7 +295,16 @@ it("advertises full host-absolute path constraints after SDK schema conversion",
|
||||
validate(valid),
|
||||
`${name}.${field}: ${JSON.stringify(validate.errors)}`,
|
||||
).toBe(true);
|
||||
checked.push(`${name}.${field}`);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"open_binary.path",
|
||||
"extract_firmware.output_directory",
|
||||
"inspect_go_binary.path NUL",
|
||||
"compare_bundles.left_bundle_path empty",
|
||||
]),
|
||||
);
|
||||
expect(firmwareCalls).toEqual([]);
|
||||
} finally {
|
||||
await close();
|
||||
@@ -299,19 +315,39 @@ it("rejects relative paths through in-memory MCP handlers", async () => {
|
||||
const firmwareCalls: string[] = [];
|
||||
const { client, close } = await createMcp(firmwareCalls);
|
||||
try {
|
||||
for (const { name, field, invalid, issuePath } of inputCases(
|
||||
resolve("rea-absolute-input"),
|
||||
"relative-input",
|
||||
)) {
|
||||
const cases = inputCases(resolve("rea-absolute-input"), "relative-input");
|
||||
const checked: string[] = [];
|
||||
for (const { name, field, invalid, issuePath } of cases) {
|
||||
const result = await client.callTool({ name, arguments: invalid });
|
||||
expect(result.isError, `${name}.${field}`).toBe(true);
|
||||
expect(JSON.stringify(result), `${name}.${field}`).toContain("absolute");
|
||||
if (issuePath !== undefined)
|
||||
expect(parseMcpToolError(result).error.details).toMatchObject({
|
||||
operation: name,
|
||||
issues: [{ path: issuePath, reason: "invalid_format" }],
|
||||
});
|
||||
expect(parseMcpToolError(result).error, `${name}.${field}`).toMatchObject(
|
||||
{
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: name,
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: issuePath,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
checked.push(`${name}.${field}`);
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining([
|
||||
"open_binary.path",
|
||||
"open_binary.snapshot_path",
|
||||
"extract_firmware.path",
|
||||
"extract_firmware.output_directory",
|
||||
"inspect_analysis_view.source.path",
|
||||
"capture_browser_scenario.browser.executable_path",
|
||||
"capture_electron_scenario.application_root",
|
||||
]),
|
||||
);
|
||||
expect(firmwareCalls).toEqual([]);
|
||||
} finally {
|
||||
await close();
|
||||
|
||||
@@ -709,8 +709,19 @@ describe("session filesystem path boundaries over MCP", () => {
|
||||
name: "export_evidence_bundle",
|
||||
arguments: { path: "relative-bundle.json" },
|
||||
});
|
||||
expect(exported.isError, JSON.stringify(exported.content)).toBe(true);
|
||||
expect(JSON.stringify(exported.content)).toContain("absolute");
|
||||
expect(parseMcpToolError(exported).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "export_evidence_bundle",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const opened = await mcp.callTool({
|
||||
name: "open_binary",
|
||||
@@ -721,8 +732,19 @@ describe("session filesystem path boundaries over MCP", () => {
|
||||
name: "close_binary",
|
||||
arguments: { snapshot_path: "relative-analysis.json" },
|
||||
});
|
||||
expect(closed.isError, JSON.stringify(closed.content)).toBe(true);
|
||||
expect(JSON.stringify(closed.content)).toContain("absolute");
|
||||
expect(parseMcpToolError(closed).error).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "close_binary",
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
path: ["snapshot_path"],
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await mcp.callTool({ name: "close_binary", arguments: {} });
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
@@ -383,7 +383,7 @@ it("attributes rejected WASM paths to their selected fields before provider work
|
||||
},
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
for (const [input, paths] of [
|
||||
const relativeCases = [
|
||||
[{ path: "relative.wasm" }, [["path"]]],
|
||||
[
|
||||
{ path: value.artifact.path, glue_paths: ["relative.js"] },
|
||||
@@ -412,7 +412,9 @@ it("attributes rejected WASM paths to their selected fields before provider work
|
||||
},
|
||||
[["path"], ["glue_paths", 0], ["candidate_paths", 0]],
|
||||
],
|
||||
] as const) {
|
||||
] as const;
|
||||
const checked: string[] = [];
|
||||
for (const [input, paths] of relativeCases) {
|
||||
const response = await harness.client.callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: input,
|
||||
@@ -423,14 +425,19 @@ it("attributes rejected WASM paths to their selected fields before provider work
|
||||
.object({ issues: z.array(z.unknown()) })
|
||||
.parse(failure.details);
|
||||
expect(issues).toEqual(
|
||||
paths.map((path) => ({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
message: "must be an absolute local filesystem path",
|
||||
})),
|
||||
paths.map((path) =>
|
||||
expect.objectContaining({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
),
|
||||
);
|
||||
checked.push(paths.map((path) => path.join(".")).join("+"));
|
||||
}
|
||||
expect(checked).toEqual(
|
||||
expect.arrayContaining(["path", "glue_paths.0", "candidate_paths.0"]),
|
||||
);
|
||||
expect(inspect).not.toHaveBeenCalled();
|
||||
const valid = await harness.client.callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
@@ -760,12 +767,13 @@ it("rejects malformed paths, refuses WASM summary delivery before provider work
|
||||
expect(
|
||||
z.object({ issues: z.array(z.unknown()) }).parse(rejected.details).issues,
|
||||
).toEqual(
|
||||
[["path"], ["glue_paths", 0]].map((path) => ({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
message: "must be an absolute local filesystem path",
|
||||
})),
|
||||
[["path"], ["glue_paths", 0]].map((path) =>
|
||||
expect.objectContaining({
|
||||
path,
|
||||
reason: "invalid_format",
|
||||
expected: "regex",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(inspect).not.toHaveBeenCalled();
|
||||
for (const input of [
|
||||
|
||||
@@ -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,205 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PYTHON_HELPERS,
|
||||
PYTHON_IMPORTS,
|
||||
} from "../../../src/domain/desktop/appExportPython.js";
|
||||
import {
|
||||
detectStateFormat,
|
||||
textualView,
|
||||
} from "../../../src/domain/desktop/stateFormats.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const arrays = "[".repeat(1500) + "1" + "]".repeat(1500);
|
||||
const objects = '{"a":'.repeat(1500) + "1" + "}".repeat(1500);
|
||||
const cases = [
|
||||
["discarded arrays", `{"value":${arrays},"value":"done"}`, true],
|
||||
["discarded objects", `{"value":${objects},"value":"done"}`, true],
|
||||
["escaped duplicate key", `{"val\\u0075e":${arrays},"value":"done"}`, true],
|
||||
["retained deep value", `{"value":${arrays}}`, false],
|
||||
[
|
||||
"retained deep inexact number",
|
||||
`{"value":${"[".repeat(600)}9007199254740993${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep overwritten inexact number",
|
||||
`{"value":${"[".repeat(600)}{"x":9007199254740993,"x":1}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep empty-key overwrite",
|
||||
`{"value":${"[".repeat(600)}{"":9007199254740993,"":1}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep multiple inexact keys with one overwritten",
|
||||
`{"value":${"[".repeat(600)}{"a":9007199254740993,"b":9007199254740993,"a":1}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep multiple inexact keys all overwritten",
|
||||
`{"value":${"[".repeat(600)}{"a":9007199254740993,"b":9007199254740993,"a":1,"b":2}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep inexact key replaced by inexact",
|
||||
`{"value":${"[".repeat(600)}{"a":9007199254740993,"a":9007199254740995}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"deep escaped duplicate overwritten",
|
||||
`{"value":${"[".repeat(600)}{"\\u0061":9007199254740993,"a":1}${"]".repeat(600)}}`,
|
||||
false,
|
||||
],
|
||||
[
|
||||
"discarded deep inexact subtree",
|
||||
`{"value":${"[".repeat(600)}9007199254740993${"]".repeat(600)},"value":"done"}`,
|
||||
true,
|
||||
],
|
||||
["last admitted depth", "[".repeat(500) + "1" + "]".repeat(500), true],
|
||||
["empty container at limit", "[".repeat(501) + "]".repeat(501), true],
|
||||
["empty container over limit", "[".repeat(502) + "]".repeat(502), false],
|
||||
["trailing comma", `{"value":${arrays},"value":"done",}`, false],
|
||||
["missing value", `{"value":${arrays},"value":}`, false],
|
||||
["wrong closing bracket", `{"value":${arrays},"value":"done"]`, false],
|
||||
] as const;
|
||||
|
||||
it.each(cases)("reads JSON with %s", async (_name, text, readable) => {
|
||||
const root = await createTestTempDirectory("desktop-json-depth-");
|
||||
const script = join(root, "read.py");
|
||||
const imports = PYTHON_IMPORTS.split("\n")
|
||||
.filter((line) => !line.includes("agentenv_protocol"))
|
||||
.join("\n");
|
||||
await writeFile(
|
||||
script,
|
||||
`${imports}${PYTHON_HELPERS}
|
||||
text = json.load(sys.stdin)
|
||||
print(json.dumps(_text_view("state.json", text, len(text.encode("utf-8")))))
|
||||
`,
|
||||
);
|
||||
const output = JSON.parse(
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", "-X", "utf8", script],
|
||||
{ input: JSON.stringify(text), encoding: "utf8" },
|
||||
),
|
||||
) as { format: string; view: unknown; error?: string };
|
||||
const bytes = Buffer.from(text);
|
||||
const format = detectStateFormat("state.json", bytes);
|
||||
if (format !== "json" && format !== "text")
|
||||
throw new Error(`Unexpected native format ${format}`);
|
||||
const native = textualView(format, bytes);
|
||||
expect(format === "json" && native.ok).toBe(readable);
|
||||
expect(output.format).toBe(format);
|
||||
if (native.ok) {
|
||||
expect(output.error).toBeUndefined();
|
||||
// Compare JSON values on their wire form, independent of object prototypes.
|
||||
expect(JSON.stringify(output.view)).toBe(JSON.stringify(native.value));
|
||||
if (
|
||||
readable &&
|
||||
(_name.startsWith("discarded") || _name === "escaped duplicate key")
|
||||
)
|
||||
expect(output.view).toEqual({ value: "done" });
|
||||
} else {
|
||||
expect(output.view).toBeNull();
|
||||
expect(output.error).toBe(native.error);
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds construction before refusing retained excessive JSON depth", async () => {
|
||||
const root = await createTestTempDirectory("desktop-json-construction-");
|
||||
const script = join(root, "bounded.py");
|
||||
const imports = PYTHON_IMPORTS.split("\n")
|
||||
.filter((line) => !line.includes("agentenv_protocol"))
|
||||
.join("\n");
|
||||
await writeFile(
|
||||
script,
|
||||
`${imports}${PYTHON_HELPERS}
|
||||
import tracemalloc
|
||||
text = "[" * 20_000 + "0" + "]" * 20_000
|
||||
recursion_limit = sys.getrecursionlimit()
|
||||
tracemalloc.start()
|
||||
entry = _json_view(text, len(text))
|
||||
peak = tracemalloc.get_traced_memory()[1]
|
||||
tracemalloc.stop()
|
||||
print(json.dumps({"entry": entry, "peak": peak, "recursion_unchanged": sys.getrecursionlimit() == recursion_limit}))
|
||||
`,
|
||||
);
|
||||
const output = JSON.parse(
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", "-X", "utf8", script],
|
||||
{ encoding: "utf8" },
|
||||
),
|
||||
) as { entry: unknown; peak: number; recursion_unchanged: boolean };
|
||||
expect(output.entry).toEqual({
|
||||
format: "json",
|
||||
view: null,
|
||||
error: "nested deeper than the 500 levels a JSON view keeps",
|
||||
});
|
||||
expect(output.recursion_unchanged).toBe(true);
|
||||
// Python heap only, excluding source construction and interpreter/OS memory.
|
||||
// A value retained only to refuse it must not allocate every input container.
|
||||
expect(output.peak).toBeLessThan(1024 * 1024);
|
||||
});
|
||||
|
||||
it("bounds allocations when a very deep value is overwritten", async () => {
|
||||
const root = await createTestTempDirectory("desktop-json-memory-");
|
||||
const script = join(root, "read.py");
|
||||
const imports = PYTHON_IMPORTS.split("\n")
|
||||
.filter((line) => !line.includes("agentenv_protocol"))
|
||||
.join("\n");
|
||||
await writeFile(
|
||||
script,
|
||||
`${imports}\nimport tracemalloc\n${PYTHON_HELPERS}
|
||||
text = '{"value":' + '[' * 200000 + '1' + ']' * 200000 + ',"value":"done"}'
|
||||
tracemalloc.start()
|
||||
result = _json_view(text, len(text))
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
print(json.dumps({"view": result["view"], "peak": peak}))
|
||||
`,
|
||||
);
|
||||
const measured = JSON.parse(
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", script],
|
||||
{ encoding: "utf8", timeout: 30000 },
|
||||
),
|
||||
) as { view: unknown; peak: number };
|
||||
expect(measured.view).toEqual({ value: "done" });
|
||||
expect(measured.peak).toBeLessThan(8 * 1024 * 1024);
|
||||
}, 40000);
|
||||
|
||||
it("bounds a wide container already beyond the retained view depth", async () => {
|
||||
const root = await createTestTempDirectory("desktop-json-wide-depth-");
|
||||
const script = join(root, "read.py");
|
||||
const imports = PYTHON_IMPORTS.split("\n")
|
||||
.filter((line) => !line.includes("agentenv_protocol"))
|
||||
.join("\n");
|
||||
await writeFile(
|
||||
script,
|
||||
`${imports}\nimport tracemalloc\n${PYTHON_HELPERS}
|
||||
text = '[' * 501 + ','.join(['"abc"'] * 600000) + ']' * 501
|
||||
tracemalloc.start()
|
||||
result = _json_view(text, len(text))
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
print(json.dumps({"error": result.get("error"), "peak": peak}))
|
||||
`,
|
||||
);
|
||||
const measured = JSON.parse(
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", script],
|
||||
{ encoding: "utf8", timeout: 30000 },
|
||||
),
|
||||
) as { error: string; peak: number };
|
||||
expect(measured.error).toBe(
|
||||
"nested deeper than the 500 levels a JSON view keeps",
|
||||
);
|
||||
expect(measured.peak).toBeLessThan(8 * 1024 * 1024);
|
||||
}, 40000);
|
||||
@@ -34,7 +34,7 @@ const execPython = (
|
||||
try {
|
||||
writeFileSync(script, args[codeIndex + 1]!, "utf8");
|
||||
return execFileSync(
|
||||
"python3",
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
[
|
||||
"-X",
|
||||
"utf8",
|
||||
@@ -375,15 +375,16 @@ it("checks text for JSON the way REA does, token by token, without parsing it",
|
||||
"-I",
|
||||
"-B",
|
||||
"-c",
|
||||
`${imports}${PYTHON_HELPERS}\nprint(json.dumps([_is_json(text) for text in json.load(sys.stdin)]))`,
|
||||
`${imports}${PYTHON_HELPERS}\ntexts = json.load(sys.stdin)\nprint(json.dumps({"syntax": [_read_json(text, build=False) is not _INVALID_JSON for text in texts], "built": [_read_json(text) is not _INVALID_JSON for text in texts]}))`,
|
||||
],
|
||||
{ input: JSON.stringify(texts), encoding: "utf8" },
|
||||
),
|
||||
) as boolean[];
|
||||
) as { syntax: boolean[]; built: boolean[] };
|
||||
|
||||
const parsed = texts.map((text) => safeParseJson(text).ok);
|
||||
expect(texts.map((text) => isJsonText(text))).toEqual(parsed);
|
||||
expect(python).toEqual(parsed);
|
||||
expect(python.syntax).toEqual(parsed);
|
||||
expect(python.built).toEqual(parsed);
|
||||
expect(parsed.filter(Boolean)).toHaveLength(10);
|
||||
});
|
||||
|
||||
@@ -445,9 +446,13 @@ print(json.dumps([_text_view("settings.ini", text, len(text.encode()))["view"]["
|
||||
const root = await createTestTempDirectory("rea-keyfile-python-");
|
||||
const script = join(root, "fixture.py");
|
||||
await writeFile(script, program, "utf8");
|
||||
const output = execFileSync("python3", ["-I", "-B", "-X", "utf8", script], {
|
||||
input: JSON.stringify(inputs),
|
||||
encoding: "utf8",
|
||||
});
|
||||
const output = execFileSync(
|
||||
process.platform === "win32" ? "python" : "python3",
|
||||
["-I", "-B", "-X", "utf8", script],
|
||||
{
|
||||
input: JSON.stringify(inputs),
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(JSON.parse(output)).toEqual(inputs.map(keyfileView));
|
||||
});
|
||||
@@ -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