397 lines
13 KiB
JavaScript
397 lines
13 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import { access, mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { inspectGhidraInstallation } from "../../dist/ghidra/GhidraInstallation.js";
|
|
|
|
/** Exercise qualified annotation names against real demangled C++ ABI symbols. */
|
|
export async function verifyGhidraNamespaceAnnotations({
|
|
call,
|
|
reject,
|
|
target,
|
|
entrypoint,
|
|
env,
|
|
}) {
|
|
await requireNamespaceDemangler(env);
|
|
const exec = promisify(execFile);
|
|
const compiler = process.env.REA_CC ?? "cc";
|
|
try {
|
|
await exec(compiler, ["--version"], { env, timeout: 30000 });
|
|
} catch (cause) {
|
|
throw new Error(
|
|
`Ghidra namespace annotation verification requires a working host C compiler '${compiler}'.`,
|
|
{ cause },
|
|
);
|
|
}
|
|
const workspace = await mkdtemp(join(tmpdir(), "rea-ghidra-namespaces-"));
|
|
const source = fileURLToPath(
|
|
new URL("../../tests/conformance/ghidra/namespaces.c", import.meta.url),
|
|
);
|
|
const path = join(workspace, "namespaces");
|
|
try {
|
|
await exec(compiler, ["-O0", "-fno-inline", source, "-o", path], {
|
|
env,
|
|
timeout: 30000,
|
|
});
|
|
const bytes = await readFile(path);
|
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
await call("close_binary");
|
|
await call("open_binary", { path, provider_id: "ghidra" });
|
|
const procedures = await call("list_procedures");
|
|
const namespaces = ["alpha", "beta", "outer::inner"];
|
|
const entries = namespaces.map((namespace) => {
|
|
const procedure = procedures.find(
|
|
({ value }) => value === `${namespace}::same`,
|
|
);
|
|
assert.ok(
|
|
procedure,
|
|
`Missing demangled fixture function ${namespace}::same`,
|
|
);
|
|
return { namespace, address: procedure.address };
|
|
});
|
|
const names = await call("list_names");
|
|
const globals = ["alpha", "outer::inner"].map((namespace) => {
|
|
const symbol = names.find(
|
|
(item) => item.value === `${namespace}::value` && item.symbol.primary,
|
|
);
|
|
assert.ok(symbol, `Missing demangled fixture global ${namespace}::value`);
|
|
return { namespace, address: symbol.address };
|
|
});
|
|
await verifyDuplicateAddressNames(call, procedures, names);
|
|
const ambiguous = await reject("procedure_address", { procedure: "same" });
|
|
assert.equal(ambiguous.code, "invalid_request");
|
|
for (const { address } of entries)
|
|
assert.ok(JSON.stringify(ambiguous.details.issues).includes(address));
|
|
for (const { namespace, address } of entries) {
|
|
const leaf = await call("annotate_native_function", {
|
|
procedure: address,
|
|
name: "renamed",
|
|
comment: "Preserve this comment",
|
|
inline_comment: "Preserve this inline comment",
|
|
});
|
|
assert.equal(leaf.annotations.name, `${namespace}::renamed`);
|
|
const qualified = await call("annotate_native_function", {
|
|
procedure: address,
|
|
name: `${namespace}::qualified`,
|
|
});
|
|
assert.equal(qualified.annotations.name, `${namespace}::qualified`);
|
|
for (let repeat = 0; repeat < 2; repeat++) {
|
|
const replay = await call("annotate_native_function", {
|
|
procedure: qualified.annotations.name,
|
|
name: qualified.annotations.name,
|
|
});
|
|
assert.deepEqual(replay.annotations, qualified.annotations);
|
|
assert.equal(replay.dossier.procedure.address, address);
|
|
}
|
|
assert.equal(
|
|
await call("procedure_address", {
|
|
procedure: qualified.annotations.name,
|
|
}),
|
|
address,
|
|
);
|
|
for (const name of [
|
|
"other::literal",
|
|
`${namespace}::child::literal`,
|
|
"0xnothex",
|
|
"address%GG:0x1",
|
|
"address:0xnothex",
|
|
]) {
|
|
const literal = await call("annotate_native_function", {
|
|
procedure: address,
|
|
name,
|
|
});
|
|
const expected = name.startsWith(`${namespace}::`)
|
|
? name
|
|
: `${namespace}::${name}`;
|
|
assert.equal(literal.annotations.name, expected);
|
|
const repeated = await call("annotate_native_function", {
|
|
procedure: expected,
|
|
name: expected,
|
|
});
|
|
assert.deepEqual(repeated.annotations, literal.annotations);
|
|
}
|
|
await call("annotate_native_function", {
|
|
procedure: address,
|
|
name: qualified.annotations.name,
|
|
});
|
|
const failure = await reject("annotate_native_function", {
|
|
procedure: address,
|
|
name: `${namespace}::`,
|
|
comment: "MUST NOT REPLACE",
|
|
inline_comment: "MUST NOT REPLACE",
|
|
});
|
|
assert.equal(failure.code, "invalid_request");
|
|
assert.deepEqual(failure.details.issues[0].path, ["name"]);
|
|
assert.match(failure.details.issues[0].message, /empty leaf/u);
|
|
const unchanged = await call("annotate_native_function", {
|
|
procedure: address,
|
|
name: qualified.annotations.name,
|
|
});
|
|
assert.deepEqual(unchanged.annotations, qualified.annotations);
|
|
}
|
|
await verifyAddressNames(call, [entries[0], entries[2]], "function");
|
|
await verifyAddressNames(call, globals, "label");
|
|
for (const { namespace, address } of entries) {
|
|
assert.equal(
|
|
await call("set_address_name", {
|
|
address,
|
|
name: `${namespace}::qualified`,
|
|
}),
|
|
true,
|
|
);
|
|
}
|
|
const { stdout } = await exec(
|
|
process.execPath,
|
|
[
|
|
entrypoint,
|
|
"annotate-native-function",
|
|
path,
|
|
"alpha::same",
|
|
"--name",
|
|
"alpha::cli_renamed",
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{ env, timeout: 240000, maxBuffer: 72 * 1024 * 1024 },
|
|
);
|
|
assert.equal(
|
|
JSON.parse(stdout).normalized_result.annotations.name,
|
|
"alpha::cli_renamed",
|
|
);
|
|
assert.equal(
|
|
(await call("analyze_function", { procedure: entries[0].address }))
|
|
.procedure.name,
|
|
"alpha::qualified",
|
|
"The independent CLI session must not alter the active MCP database",
|
|
);
|
|
assert.deepEqual(await readFile(path), bytes);
|
|
assert.equal((await call("binary_session")).sha256, sha256);
|
|
await call("close_binary");
|
|
await call("open_binary", { path: target.path, provider_id: "ghidra" });
|
|
} finally {
|
|
await rm(workspace, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function verifyDuplicateAddressNames(call, procedures, names) {
|
|
const functionEntries = [
|
|
"rea_duplicate_function_a",
|
|
"rea_duplicate_function_b",
|
|
].map((name) => {
|
|
const procedure = procedures.find(
|
|
({ value }) => value === name || value === `_${name}`,
|
|
);
|
|
assert.ok(procedure, `Missing duplicate-name fixture function ${name}`);
|
|
return { address: procedure.address, original: procedure.value };
|
|
});
|
|
const labelEntries = ["rea_duplicate_label_a", "rea_duplicate_label_b"].map(
|
|
(name) => {
|
|
const symbol = names.find(
|
|
(item) =>
|
|
(item.value === name || item.value === `_${name}`) &&
|
|
item.symbol.primary,
|
|
);
|
|
assert.ok(symbol, `Missing duplicate-name fixture label ${name}`);
|
|
return { address: symbol.address, original: symbol.value };
|
|
},
|
|
);
|
|
|
|
for (const [kind, entries] of [
|
|
["function", functionEntries],
|
|
["label", labelEntries],
|
|
]) {
|
|
const [owner, duplicate] = entries;
|
|
const name = `rea_duplicate_${kind}`;
|
|
assert.equal(
|
|
await call("set_address_name", { address: owner.address, name }),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
await call("set_address_name", { address: owner.address, name }),
|
|
true,
|
|
"Renaming the current owner to its existing name remains idempotent",
|
|
);
|
|
assert.equal(
|
|
await call("set_address_name", { address: duplicate.address, name }),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
await call("address_name", { address: duplicate.address }),
|
|
duplicate.original,
|
|
"A rejected duplicate leaves the other address unchanged",
|
|
);
|
|
|
|
const batchName = `rea_batch_duplicate_${kind}`;
|
|
assert.deepEqual(
|
|
await call("set_addresses_names", {
|
|
names: {
|
|
[owner.address]: batchName,
|
|
[duplicate.address]: batchName,
|
|
},
|
|
}),
|
|
{ [owner.address]: true, [duplicate.address]: false },
|
|
);
|
|
assert.equal(
|
|
await call("address_name", { address: owner.address }),
|
|
batchName,
|
|
);
|
|
assert.equal(
|
|
await call("address_name", { address: duplicate.address }),
|
|
duplicate.original,
|
|
"A failed batch member rolls back only its own address",
|
|
);
|
|
const owners = (await call("list_names")).filter(
|
|
(item) => item.value === batchName && item.symbol.primary,
|
|
);
|
|
assert.deepEqual(
|
|
owners.map(({ address }) => address),
|
|
[owner.address],
|
|
);
|
|
}
|
|
}
|
|
|
|
async function verifyAddressNames(call, symbols, kind) {
|
|
for (const { namespace, address } of symbols) {
|
|
assert.equal(
|
|
await call("set_address_name", {
|
|
address,
|
|
name: `address_renamed_${kind}`,
|
|
}),
|
|
true,
|
|
);
|
|
const qualified = `${namespace}::address_qualified_${kind}`;
|
|
assert.equal(
|
|
await call("set_address_name", { address, name: qualified }),
|
|
true,
|
|
);
|
|
assert.equal(await call("address_name", { address }), qualified);
|
|
assert.equal(
|
|
await call("set_address_name", {
|
|
address,
|
|
name: `other::literal_${kind}`,
|
|
}),
|
|
true,
|
|
);
|
|
const literal = await call("address_name", { address });
|
|
assert.equal(literal, `${namespace}::other::literal_${kind}`);
|
|
assert.equal(
|
|
await call("set_address_name", { address, name: literal }),
|
|
true,
|
|
);
|
|
assert.equal(await call("address_name", { address }), literal);
|
|
assert.equal(
|
|
await call("set_address_name", { address, name: qualified }),
|
|
true,
|
|
);
|
|
for (let repeat = 0; repeat < 2; repeat++) {
|
|
const readback = await call("address_name", { address });
|
|
assert.deepEqual(
|
|
await call("set_addresses_names", { names: { [address]: readback } }),
|
|
{ [address]: true },
|
|
);
|
|
assert.equal(await call("address_name", { address }), qualified);
|
|
}
|
|
assert.equal(
|
|
await call("set_address_name", { address, name: `${namespace}::` }),
|
|
false,
|
|
);
|
|
const session = await call("binary_session");
|
|
for (const operation of [
|
|
"set_address_name",
|
|
"set_addresses_names",
|
|
"address_name",
|
|
"list_names",
|
|
])
|
|
assert.equal(
|
|
session.capabilities.find((item) => item.operation === operation)
|
|
?.available,
|
|
true,
|
|
`An invalid name must not mark ${operation} unavailable`,
|
|
);
|
|
assert.equal(await call("address_name", { address }), qualified);
|
|
}
|
|
const [valid, invalid] = symbols;
|
|
assert.deepEqual(
|
|
await call("set_addresses_names", {
|
|
names: {
|
|
[valid.address]: `${valid.namespace}::batch_renamed_${kind}`,
|
|
[invalid.address]: `${invalid.namespace}::`,
|
|
},
|
|
}),
|
|
{ [valid.address]: true, [invalid.address]: false },
|
|
);
|
|
assert.equal(
|
|
await call("address_name", { address: valid.address }),
|
|
`${valid.namespace}::batch_renamed_${kind}`,
|
|
);
|
|
assert.equal(
|
|
await call("address_name", { address: invalid.address }),
|
|
`${invalid.namespace}::address_qualified_${kind}`,
|
|
);
|
|
const names = await call("list_names");
|
|
for (const { namespace, address } of symbols) {
|
|
const expected =
|
|
address === valid.address
|
|
? `${namespace}::batch_renamed_${kind}`
|
|
: `${namespace}::address_qualified_${kind}`;
|
|
assert.ok(
|
|
names.some(
|
|
(item) =>
|
|
item.address === address &&
|
|
item.value === expected &&
|
|
item.symbol.primary,
|
|
),
|
|
`Missing refreshed primary symbol ${expected}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function requireNamespaceDemangler(env) {
|
|
const inspection = inspectGhidraInstallation({
|
|
environment: process.env,
|
|
installDir: env.GHIDRA_INSTALL_DIR,
|
|
...(env.JAVA_HOME === undefined ? {} : { javaHome: env.JAVA_HOME }),
|
|
});
|
|
if (
|
|
inspection.status !== "available" ||
|
|
inspection.nativeDecompilerPath === null
|
|
)
|
|
throw new Error(
|
|
`Ghidra namespace verification requires an available installation: ${JSON.stringify(inspection)}`,
|
|
);
|
|
const nativeDirectory = basename(dirname(inspection.nativeDecompilerPath));
|
|
const executable =
|
|
process.platform === "win32"
|
|
? "demangler_gnu_v2_41.exe"
|
|
: "demangler_gnu_v2_41";
|
|
const failures = [];
|
|
for (const location of ["os", "build/os"]) {
|
|
const path = join(
|
|
inspection.installDir,
|
|
"GPL",
|
|
"DemanglerGnu",
|
|
location,
|
|
nativeDirectory,
|
|
executable,
|
|
);
|
|
try {
|
|
await access(path, constants.X_OK);
|
|
if ((await stat(path)).isFile()) return;
|
|
failures.push(`${path}: not a regular file`);
|
|
} catch (cause) {
|
|
failures.push(
|
|
`${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
);
|
|
}
|
|
}
|
|
throw new Error(
|
|
`Ghidra namespace annotation verification requires the matching GNU demangler native component. Provide ${executable} for ${nativeDirectory} in GPL/DemanglerGnu/os or its build/os equivalent; REA does not build it. Checked: ${failures.join("; ")}`,
|
|
);
|
|
}
|