Files
rea/tests/boundary/cli/idaStartupAttributionCli.test.ts
T

310 lines
8.8 KiB
TypeScript

import { createVerifierHttpServer } from "../../../scripts/lib/verifier-http-server.mjs";
import type { IncomingMessage, ServerResponse } from "node:http";
import { expect } from "vitest";
import { z } from "zod";
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
import { elf } from "../../../src/domain/binaryTarget.fixture.js";
const tools = [
"idb_open",
"idb_list",
"idb_close",
"server_health",
"list_funcs",
"lookup_funcs",
"find_regex",
"decompile",
"disasm",
"callees",
"xrefs_to",
];
const requestSchema = z.object({
id: z.union([z.string(), z.number()]).optional(),
method: z.string(),
params: z
.object({
protocolVersion: z.string().optional(),
name: z.string().optional(),
arguments: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
});
type StartupMode =
| "missing-profile"
| "connect-timeout"
| "catalog-timeout"
| "catalog-refused"
| "headless-input-mismatch"
| "unexpected-worker"
| "success";
type StartupSupervisorState = {
readonly mode: StartupMode;
readonly calls: string[];
database: string | undefined;
input: string | undefined;
};
const handleStartupRequest = async (
state: StartupSupervisorState,
request: IncomingMessage,
response: ServerResponse,
) => {
if (request.method !== "POST") {
response.writeHead(405).end();
return;
}
let body = "";
for await (const chunk of request) body += String(chunk);
const rpc = requestSchema.parse(JSON.parse(body));
if (rpc.id === undefined) {
response.writeHead(202).end();
return;
}
const send = (result: unknown) =>
response
.writeHead(200, { "Content-Type": "application/json" })
.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
if (rpc.method === "initialize") {
if (state.mode === "connect-timeout") return;
send({
protocolVersion: rpc.params?.protocolVersion,
capabilities: { tools: {} },
serverInfo: { name: "ida-startup-fixture", version: "1" },
});
return;
}
if (rpc.method === "tools/list") {
if (state.mode === "catalog-timeout") return;
if (state.mode === "catalog-refused") {
response.writeHead(200, { "Content-Type": "application/json" }).end(
JSON.stringify({
jsonrpc: "2.0",
id: rpc.id,
error: { code: -32603, message: "fixture catalog refused" },
}),
);
return;
}
send({
tools: tools
.filter(
(name) => state.mode !== "missing-profile" || name !== "list_funcs",
)
.map((name) => ({
name,
inputSchema: { type: "object", properties: {} },
})),
});
return;
}
const name = rpc.params?.name;
const args = rpc.params?.arguments;
if (name === undefined || args === undefined)
throw new Error("Unexpected RPC");
state.calls.push(name);
let result: unknown;
if (name === "idb_open") {
state.database = z.string().parse(args.preferred_session_id);
state.input = z.string().parse(args.input_path);
result = {
success: true,
session: {
session_id:
state.mode === "unexpected-worker"
? "unexpected-worker"
: state.database,
input_path: state.input,
},
};
} else if (name === "idb_list")
result = {
sessions:
state.database === undefined
? []
: [
{
session_id: state.database,
input_path: state.input,
owned: true,
backend: "worker",
is_active: true,
},
],
};
else if (name === "idb_close") {
result = {
success: true,
session_id: state.database,
saved: null,
owned: true,
backend: "worker",
};
state.database = undefined;
} else if (name === "server_health")
result = {
status: "ok",
input_path:
state.mode === "headless-input-mismatch"
? state.input + ".other"
: state.input,
idb_path: state.input + ".i64",
module: "target.elf",
imagebase: "0x1000",
auto_analysis_ready: true,
};
else if (name === "lookup_funcs")
result = z
.array(z.string())
.parse(args.queries)
.map((query) => ({
query,
fn: { addr: "0x1000", name: "main", size: "0x20" },
error: null,
}));
else if (name === "decompile")
result = {
code: "int main(void) { return 42; }",
cursor: { done: true },
};
else throw new Error("Unexpected tool: " + name);
send({ content: [{ type: "text", text: JSON.stringify(result) }] });
};
const createStartupSupervisor = async (mode: StartupMode) => {
const state: StartupSupervisorState = {
mode,
calls: [],
database: undefined,
input: undefined,
};
const { server, close } = createVerifierHttpServer((request, response) =>
handleStartupRequest(state, request, response),
);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
return {
server,
calls: state.calls,
get database() {
return state.database;
},
close,
};
};
workspaceCliTest.for([
"missing-profile",
"connect-timeout",
"catalog-timeout",
"catalog-refused",
"headless-input-mismatch",
"unexpected-worker",
"success",
] as const)(
"attributes selected IDA startup to the public analysis: %s",
async (mode, { cli, workspace }) => {
const supervisor = await createStartupSupervisor(mode);
const { server, calls } = supervisor;
try {
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Missing server address");
const workspaceRoot = await workspace.mkdir("ida-workspaces");
const registration = await workspace.write(
"ida.json",
JSON.stringify({
url: `http://127.0.0.1:${address.port}/mcp`,
mode: "headless",
timeoutMs: 500,
workspaceRoot,
}),
);
const target = await workspace.write("target.elf", elf(2, 1, 62));
const result = await cli.run({
arguments: ["decompile", target, "main", "--provider", "ida", "--json"],
environment: {
...workspace.environment,
REA_IDA_MCP_CONFIG: registration,
REA_LOG_LEVEL: "silent",
},
timeoutMs: 15000,
});
expect(result.stderr).toBe("");
expect(supervisor.database).toBeUndefined();
if (mode === "success") {
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
operation: "procedure_pseudo_code",
normalized_result: "int main(void) { return 42; }",
});
expect(calls).toContain("decompile");
expect(calls).toContain("idb_close");
return;
}
expect(result.exitCode).toBe(1);
if (mode === "headless-input-mismatch" || mode === "unexpected-worker") {
expect(calls).not.toContain("decompile");
expect(calls).toContain("idb_close");
expect(result.json).toMatchObject({
code: "unreadable_output",
retryable: false,
message: expect.stringContaining(
mode === "headless-input-mismatch"
? "input identity changed"
: "unexpected session",
),
remediation: {
action: expect.stringContaining(
mode === "headless-input-mismatch" ? "reopen" : "session ID",
),
},
details: {
operation: "procedure_pseudo_code",
diagnostics: {
identity_check:
mode === "headless-input-mismatch"
? "headless_input"
: "worker_session",
reason: expect.any(String),
recovery: expect.any(String),
},
},
});
return;
}
expect(calls).toEqual([]);
expect(result.json).toMatchObject({
code: mode.endsWith("timeout")
? "provider_timeout"
: "execution_failure",
details: { operation: "procedure_pseudo_code" },
});
if (mode === "missing-profile")
expect(result.json).toMatchObject({
details: {
diagnostics: {
missing_tools: ["list_funcs"],
startup_operation: "initialize",
},
},
});
if (mode.endsWith("timeout"))
expect(result.json).toMatchObject({
retryable: true,
details: {
timeout_ms: 500,
captured_output: { stdout: "", stderr: "", truncated: false },
diagnostics: {
startup_operation:
mode === "connect-timeout"
? "ida_mcp_connect"
: "ida_mcp_catalog",
},
},
});
} finally {
await supervisor.close();
}
},
);