Files
rea/scripts/lib/real-ghidra-namespace-annotations.mjs
Giridharandmorluto 6e0d5d64d3 feat(ghidra): admit set_address_name and set_addresses_names (#1635)
* feat(ghidra): admit set_address_name and set_addresses_names

Ghidra sessions rejected the existing naming tools with "operation is not declared by this provider", so agents had to use annotate_native_function, which returns a complete function dossier per rename and cannot name data addresses.

The Java bridge now names a local function entry (namespace-aware, like annotate_native_function) or creates/renames the primary label at any other mapped address, verifying readback. set_addresses_names applies each address in its own transaction and reports per-address booleans. Both are mutating capabilities: excluded from read-only (Windows P0) handshakes and from snapshot replay.

* fix(ghidra): normalize qualified symbol names and reject empty leaves

Share namespace-aware leaf normalization across functions and primary labels. Invalid qualified leaves now return false for naming operations without marking the provider unavailable; function annotations retain typed invalid-input errors.

Extend real CLI/MCP namespace verification with namespaced globals, readback replay, failed-name rollback, mixed batches, and capability checks.

Validation: 75 focused tests and npm run check pass. Ghidra 12.1.4/JDK 21 namespace verification passes on macOS arm64 with unchanged source bytes and complete cleanup. The full host lane passes this workflow before the existing entry-alias fixture fails because the compiler places its interior label at the function entry.

---------

Co-authored-by: morluto <[email protected]>
2026-10-11 04:35:38 +08:00

312 lines
11 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 };
});
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], ...globals]);
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 verifyAddressNames(call, symbols) {
for (const { namespace, address } of symbols) {
assert.equal(
await call("set_address_name", { address, name: "address_renamed" }),
true,
);
const qualified = `${namespace}::address_qualified`;
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" }),
true,
);
const literal = await call("address_name", { address });
assert.equal(literal, `${namespace}::other::literal`);
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`,
[invalid.address]: `${invalid.namespace}::`,
},
}),
{ [valid.address]: true, [invalid.address]: false },
);
assert.equal(
await call("address_name", { address: valid.address }),
`${valid.namespace}::batch_renamed`,
);
assert.equal(
await call("address_name", { address: invalid.address }),
`${invalid.namespace}::address_qualified`,
);
const names = await call("list_names");
for (const { namespace, address } of symbols) {
const expected =
address === valid.address
? `${namespace}::batch_renamed`
: `${namespace}::address_qualified`;
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("; ")}`,
);
}