Files
rea/tests/fixtures/fakeGhidra.mjs
91884c3743 fix(ghidra): own operation-scoped JVM failure evidence
Scan provider output before retention and keep bounded confirmed token facts
through output completion. Exclude identifier prefixes, pre-checkpoint context,
and unconfirmed tails; preserve invalid UTF-8 and directional truncation facts.

Make confirmed OOME observations authoritative for projection. Retained raw
prefixes cannot establish an OOME, and unrelated output cannot supply its
resource kind or recovery advice. Incomplete no-OOME output preserves the
primary error and explains uncertainty. Current checkpoint coverage remains
separate from historical budget loss.

Adapt the retention approach from #4774 with independently reproduced scanner
and projection counterexamples corrected before integration.

Validation: 268 focused tests, npm run check, npm run docs:check, and test
discovery (1639 files exactly once) pass. Controlled Node pipes/socket and fake
Ghidra JVM only; real Ghidra and native Linux/Windows behavior are unverified.

Fixes #4682
Fixes #5220

Co-authored-by: reason-machines[bot] <293529679+reason-machines[bot]@users.noreply.github.com>
Co-authored-by: Saim Shafique <msaimshafique4664@gmail.com>
2026-10-19 17:15:32 +08:00

502 lines
15 KiB
JavaScript

import { rename, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
const configuration = JSON.parse(
process.env.REA_GHIDRA_FIXTURE_CONFIG ?? "null",
);
const {
endpointPath,
token,
runId,
providerVersion,
profileDigest,
targetSha256,
transport,
mode = "success",
} = configuration ?? {};
if (
endpointPath === undefined ||
token === undefined ||
runId === undefined ||
providerVersion === undefined ||
profileDigest === undefined ||
targetSha256 === undefined ||
!["unix-socket", "authenticated-loopback-tcp"].includes(transport)
) {
process.exit(64);
}
const OOME_LINE =
"ERROR Abort due to Headless analyzer error: Java heap space (HeadlessAnalyzer) java.lang.OutOfMemoryError: Java heap space\n";
// Exceeds the 8 MiB diagnostic retention budget in many small chunks.
const floodStdout = async () => {
const line = "INFO fixture analyzer progress line\n";
const block = line.repeat(Math.ceil((64 * 1024) / line.length));
for (let written = 0; written < 10.5 * 1024 * 1024;) {
if (!process.stdout.write(block))
await new Promise((resolve) => process.stdout.once("drain", resolve));
written += block.length;
}
};
// The retention boundary ends exactly at the class-name prefix of a different identifier.
const cutIdentifierAtBudget = async () => {
const marker = "java.lang.OutOfMemoryError";
let remaining = 8 * 1024 * 1024 - marker.length;
const block = Buffer.alloc(64 * 1024, 0x70);
while (remaining > 0) {
const part = block.subarray(0, Math.min(remaining, block.length));
if (!process.stdout.write(part))
await new Promise((resolve) => process.stdout.once("drain", resolve));
remaining -= part.length;
}
await new Promise((resolve, reject) =>
process.stdout.write(`${marker}Custom\n`, (error) =>
error ? reject(error) : resolve(),
),
);
process.exit(74);
};
const writeThen = (stream, text, then) =>
stream.write(text, () => setTimeout(then, 10));
if (mode === "exit") process.exit(73);
if (mode === "flood_silent") {
await floodStdout();
setInterval(() => undefined, 1_000);
} else if (mode === "silent") setInterval(() => undefined, 1_000);
else {
await rm(endpointPath, { force: true });
const sockets = new Set();
let rejectedConnections = 0;
const server = createServer((socket) => {
if (
mode === "reject_all_connections" ||
(mode === "reject_first_connection" && rejectedConnections++ === 0)
) {
socket.destroy();
return;
}
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
socket.setEncoding("utf8");
const state = {
buffer: { value: "" },
pings: { value: 0 },
emittedOome: false,
mode,
token,
runId,
providerVersion,
profileDigest,
targetSha256,
};
socket.on("data", (chunk) => onSocketData(socket, server, chunk, state));
});
process.on("message", (message) => {
if (message !== "disconnect") return;
for (const socket of sockets) socket.destroy();
process.send?.({ event: "disconnected" });
});
if (transport === "unix-socket") server.listen(endpointPath);
else {
server.listen(0, "127.0.0.1", async () => {
const address = server.address();
if (address === null || typeof address === "string") process.exit(65);
const pending = `${endpointPath}.pending`;
await writeFile(
pending,
`${JSON.stringify({
host: "127.0.0.1",
port: address.port,
})}\n`,
{ flag: "wx" },
);
await rename(pending, endpointPath);
});
}
}
const onSocketData = (socket, server, chunk, state) => {
state.buffer.value += chunk;
let newline = state.buffer.value.indexOf("\n");
while (newline >= 0) {
const line = state.buffer.value.slice(0, newline);
state.buffer.value = state.buffer.value.slice(newline + 1);
newline = state.buffer.value.indexOf("\n");
const request = JSON.parse(line);
process.send?.({ method: request.method });
if (request.token !== state.token) {
socket.end(
`${JSON.stringify({
id: request.id,
ok: false,
error: { code: "auth", message: "Authentication failed" },
})}\n`,
);
continue;
}
handleRequest(socket, server, request, state);
}
};
/** Tool-request failures that follow process output, as the Ghidra JVM prints them. */
const handleOutputFailure = (socket, state) => {
const oome = () =>
writeThen(process.stderr, OOME_LINE, () => process.exit(1));
if (state.mode === "prefix_cut_identifier_exit_tools")
void cutIdentifierAtBudget();
else if (state.mode === "oom_exit_tools") oome();
else if (state.mode === "flood_then_oom_exit_tools")
void floodStdout().then(oome);
else if (state.mode === "flood_then_exit_tools")
void floodStdout().then(() => process.exit(74));
else if (state.mode === "flood_then_malformed")
void floodStdout().then(() => socket.write("{invalid\n"));
else return false;
return true;
};
const handleRequest = (socket, server, request, state) => {
if (request.method === "ping") {
handlePing(socket, request, state);
return;
}
if (
state.mode === "old_oom_then_malformed" &&
request.method === "list_documents" &&
!state.emittedOome
) {
state.emittedOome = true;
process.stderr.write(
"ERROR Abort due to Headless analyzer error: Java heap space (HeadlessAnalyzer) java.lang.OutOfMemoryError:\n",
() => setTimeout(() => handleRequest(socket, server, request, state), 10),
);
return;
}
if (
state.mode === "old_oom_then_malformed" &&
request.method === "list_procedures" &&
state.emittedOome
) {
process.stderr.write("Current operation diagnostic: 雪\n", () =>
setTimeout(() => socket.write("{invalid\n"), 10),
);
return;
}
if (state.mode === "remote_error" && request.method === "list_procedures") {
socket.write(
`${JSON.stringify({
id: request.id,
ok: false,
error: {
code: "not_found",
message: "Unknown Ghidra procedure name",
},
})}\n`,
);
return;
}
if (state.mode === "exit_tools" && request.method !== "shutdown")
process.exit(74);
if (request.method !== "shutdown" && handleOutputFailure(socket, state))
return;
if (state.mode === "hang_tools" && request.method !== "shutdown") return;
const inventory =
request.method === "list_strings" && state.mode === "fragmented"
? [stringItem("0x2000", "fixture 雪🦊")]
: request.method === "list_strings" && state.mode === "oversized_result"
? [stringItem("0x2000", "x".repeat(64 * 1024 * 1024 + 1))]
: inventoryResultFor(request.method, request.params);
if (inventory !== undefined) {
const response = `${JSON.stringify({ id: request.id, ok: true, result: inventory })}\n`;
if (state.mode === "fragmented") {
const bytes = Buffer.from(response, "utf8");
const unicodeOffset = bytes.indexOf(Buffer.from("雪", "utf8"));
const midpoint =
unicodeOffset >= 0 ? unicodeOffset + 1 : Math.floor(bytes.length / 2);
socket.write(bytes.subarray(0, midpoint));
setTimeout(() => socket.write(bytes.subarray(midpoint)), 5);
} else socket.write(response);
return;
}
if (request.method === "shutdown") {
socket.end(
`${JSON.stringify({
id: request.id,
...(state.mode === "shutdown_error"
? {
ok: false,
error: {
code: "shutdown_fixture_failure",
message: `Shutdown denied for ${state.token}: /tmp/password-fixture http://localhost/secret?token=public-value`,
},
}
: {
ok: true,
result: {
shutdown: state.mode !== "invalid_shutdown_ack",
project_ephemeral: true,
},
}),
})}\n`,
);
server.close(() => process.exit(0));
}
};
const handlePing = (socket, request, state) => {
state.pings.value += 1;
if (state.mode === "diagnostic_output" && state.pings.value === 1) {
const retainedTokenPrefixBytes = Math.floor(state.token.length / 2);
const preservedMarker = "source=/tmp/local-evidence.bin?cursor=keep&";
const fillLength = 8 * 1024 * 1024 - retainedTokenPrefixBytes;
const fill = "x".repeat(fillLength - Buffer.byteLength(preservedMarker));
process.stderr.write(`${fill}${preservedMarker}`);
setImmediate(() => {
process.stderr.write(state.token);
});
}
if (state.mode === "malformed") {
socket.write("{invalid\n");
return;
}
if (state.mode === "contradictory") {
socket.write(
`${JSON.stringify({
id: request.id,
ok: false,
result: { unexpected: true },
error: { code: "failure", message: "Failed" },
})}\n`,
);
return;
}
if (state.mode === "future_id") {
socket.write(
`${JSON.stringify({ id: request.id + 10, ok: true, result: null })}\n`,
);
return;
}
if (state.mode === "hang_after_start" && state.pings.value > 1) return;
const result = sessionInfo({
runId: state.runId,
providerVersion:
state.mode === "wrong_identity" ? "0.0.0" : state.providerVersion,
profileDigest: state.profileDigest,
targetSha256: state.targetSha256,
timedOut: state.mode === "analysis_timeout",
});
const response = `${JSON.stringify({ id: request.id, ok: true, result })}\n`;
if (state.mode === "fragmented") {
const split = Math.floor(response.length / 2);
socket.write(response.slice(0, split));
setTimeout(() => socket.write(response.slice(split)), 5);
} else socket.write(response);
};
const sessionInfo = ({
runId: sessionRunId,
providerVersion: version,
profileDigest: digest,
targetSha256: sha256,
timedOut,
}) => ({
name: "REA Ghidra bridge",
run_id: sessionRunId,
profile_digest: digest,
provider: { id: "ghidra", version },
read_only: transport !== "unix-socket",
analysis_complete: !timedOut,
analysis_timed_out: timedOut,
capabilities: [
"read_binary_inventory_summary",
"ping",
"shutdown",
"inspect_native_load_image",
"read_bytes",
"search_bytes",
"address_to_file_offset",
"address_name",
"list_documents",
"list_names",
"list_procedures",
"list_segments",
"list_strings",
"procedure_address",
"resolve_containing_procedure",
"search_procedures",
"search_strings",
"set_address_name",
"set_addresses_names",
"annotate_native_function",
"inspect_native_data_type",
"inspect_native_instruction",
"resolve_native_call_targets",
"analyze_function",
"procedure_assembly",
"procedure_callees",
"procedure_callers",
"procedure_info",
"procedure_pseudo_code",
"read_function_instructions",
"procedure_references",
"xrefs",
].filter(
(value) =>
transport === "unix-socket" ||
![
"annotate_native_function",
"set_address_name",
"set_addresses_names",
].includes(value),
),
target: {
name: "fixture",
language_id: "x86:LE:64:default",
compiler_spec_id: "gcc",
image_base: "0x1000",
default_address_space: "ram",
sha256,
},
});
const inventoryResultFor = (method, params) => {
switch (method) {
case "read_binary_inventory_summary":
return {
document: "fixture",
segments: inventoryResultFor("list_segments", {}),
procedure_count: inventoryResultFor("list_procedures", {}).length,
string_count: inventoryResultFor("list_strings", {}).length,
};
case "search_bytes": {
const continued = params.cursor === "fixture-second-page";
const paged = params.limit === 1 && !continued;
const addresses = continued
? ["0x1005"]
: paged
? ["0x1000"]
: ["0x1000", "0x1005"];
return {
matches: addresses.map((address) => ({
address,
segment: ".text",
file_offset: address === "0x1000" ? 0 : 5,
procedure: { address: "0x1000", name: "fixture_main" },
})),
searched: {
blocks: [
{
segment: ".text",
start: continued ? "0x1005" : "0x1000",
end: "0x1009",
bytes: continued ? 5 : 10,
},
],
bytes: continued ? 5 : 10,
skipped_uninitialized: [
{ segment: ".bss", start: "0x2000", end: "0x200f", bytes: 16 },
],
skipped_not_loaded: [],
},
complete: !paged,
next_cursor: paged ? "fixture-second-page" : null,
};
}
case "address_name":
return "fixture_main";
case "list_documents":
return ["fixture"];
case "list_names":
return [nameItem()];
case "list_procedures":
return [procedureItem()];
case "list_segments":
return [segmentItem()];
case "list_strings":
return [stringItem("0x2000", "fixture value")];
case "procedure_address":
return "0x1000";
case "resolve_containing_procedure":
return containingProcedureResult();
case "search_procedures":
return [procedureSearchItem()];
case "search_strings":
return [stringSearchItem()];
default:
return undefined;
}
};
const nameItem = () => ({
address: "0x1000",
value: "fixture_main",
symbol: {
primary: true,
dynamic: false,
external: false,
type: "function",
source: "analysis",
},
});
const procedureItem = () => ({
address: "0x1000",
value: "fixture_main",
procedure: { external: false, thunk: false, thunk_target: null },
});
const segmentItem = () => ({
name: ".text",
start: "0x1000",
end: "0x1100",
readable: true,
writable: false,
executable: true,
permissions: { available: true, source: "ghidra-memory-block" },
provenance: "ghidra-memory-block",
address_space: "ram",
image_base: "0x1000",
initialized: true,
overlay: false,
sections: [],
});
const stringItem = (address, value) => ({
address,
value,
string: {
encoding: "UTF-8",
termination: "present_or_not_required",
byte_length: Buffer.byteLength(value, "utf8") + 1,
},
});
const containingProcedureResult = () => ({
query_address: "0x1001",
found: true,
procedure: {
address: "0x1000",
name: "fixture_main",
classification: {
external: false,
thunk: false,
thunk_target: null,
provenance: "ghidra-function-manager",
},
},
});
const procedureSearchItem = () => ({
address: "0x1000",
value: "fixture_main",
});
const stringSearchItem = () => ({
address: "0x2000",
value: "fixture value",
});