* fix(native): find procedures by the secondary names exact lookup accepts search_procedures matched only each procedure's primary name, while exact lookup also accepts Kuna inventory aliases (versioned-symbol and generated sub_ names) and Ghidra symbols at an exact function entry. A search for a name procedure_address resolves returned nothing, and trace_feature silently missed the procedure. Both providers now match the same names their exact lookup accepts. A procedure still appears once under its primary name; the optional matched_aliases field lists the secondary names that matched, so aliases never become procedures of their own and list_procedures is unchanged. The shared domain helper owns that row rule for Kuna and for the Ghidra host-side Unicode folding pass. The Ghidra analysis profile records the new search semantics so older snapshots cannot replay alias-blind results. Fixes #8330 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ghidra): match leaf spellings of procedure names in search procedure_address accepts each entry symbol's leaf or namespace-qualified spelling, but procedure search tested only qualified names, so a regex such as ^audit_alias$ missed a secondary symbol ns::audit_alias (and ^same$ missed a primary alpha::same) that exact lookup selects. Lookup and search now share one bridge definition of the names that select a procedure, so they cannot drift. Matching secondary symbols are still reported by their qualified names; because a leaf ends its qualified spelling, the host's Unicode literal pass stays exact over those names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
8.1 KiB
JavaScript
249 lines
8.1 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
|
|
/** Verify imported entry aliases through CLI/MCP without admitting interior labels. */
|
|
export async function verifyGhidraEntryAliases({
|
|
call,
|
|
reject,
|
|
target,
|
|
entrypoint,
|
|
env,
|
|
}) {
|
|
const exec = promisify(execFile);
|
|
const compiler = process.env.REA_CC ?? "cc";
|
|
const workspace = await mkdtemp(join(tmpdir(), "rea-ghidra-entry-aliases-"));
|
|
const source = fileURLToPath(
|
|
new URL("../../tests/conformance/ghidra/entry-aliases.c", import.meta.url),
|
|
);
|
|
const path = join(workspace, "entry-aliases");
|
|
try {
|
|
await exec(compiler, ["-O0", "-g", "-fno-inline", source, "-o", path], {
|
|
env,
|
|
timeout: 30000,
|
|
});
|
|
const bytes = await readFile(path);
|
|
await call("close_binary");
|
|
const imported = await call("open_binary", { path, provider_id: "ghidra" });
|
|
const prefix = imported.format === "mach-o" ? "_" : "";
|
|
const names = await call("list_names");
|
|
const alias = names.find(
|
|
({ value }) => value === `${prefix}rea_entry_alias`,
|
|
);
|
|
assert.ok(alias, "Fixture entry alias was not imported");
|
|
assert.equal(
|
|
alias.symbol.primary,
|
|
false,
|
|
"The regression must exercise a secondary entry symbol",
|
|
);
|
|
assert.equal(alias.symbol.source, "imported");
|
|
const canonical = (await call("list_procedures")).find(
|
|
({ address }) => address === alias.address,
|
|
);
|
|
assert.ok(canonical, "Fixture alias is not at a function entry");
|
|
const xrefsSelectors = [];
|
|
// CLI xrefs keeps bare hexadecimal selectors as addresses. A Mach-O
|
|
// primary label such as "dead" is tested below as a procedure name instead.
|
|
for (const value of [
|
|
`${prefix}rea_alias_target`,
|
|
alias.value,
|
|
`${prefix}rea_xrefs_data`,
|
|
`${prefix}rea_xrefs_unreferenced`,
|
|
]) {
|
|
const symbol = names.find((item) => item.value === value);
|
|
assert.ok(symbol, `Missing xrefs selector ${value}`);
|
|
const expected = await call("xrefs", { address: symbol.address });
|
|
if (value === `${prefix}rea_xrefs_unreferenced`)
|
|
assert.deepEqual(expected, []);
|
|
else assert.ok(expected.length > 0, `Missing references for ${value}`);
|
|
xrefsSelectors.push({ symbol, expected });
|
|
}
|
|
for (const procedure of [
|
|
alias.value,
|
|
"dead",
|
|
canonical.value,
|
|
alias.address,
|
|
]) {
|
|
assert.equal(
|
|
await call("procedure_address", { procedure }),
|
|
alias.address,
|
|
);
|
|
assert.equal(
|
|
(await call("analyze_function", { procedure, detail: "complete" }))
|
|
.procedure.address,
|
|
alias.address,
|
|
);
|
|
assert.equal(
|
|
await call("procedure_pseudo_code", { procedure }),
|
|
await call("procedure_pseudo_code", { procedure: alias.address }),
|
|
);
|
|
}
|
|
// Leaf and qualified forms of the primary name must not duplicate a match.
|
|
assert.equal(
|
|
(await call("procedure_info", { procedure: alias.value })).entrypoint,
|
|
alias.address,
|
|
);
|
|
// Search matches the names exact lookup accepts, as one procedure row
|
|
// under its primary name.
|
|
const escaped = alias.value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
for (const query of [
|
|
{ pattern: alias.value },
|
|
{ pattern: `^${escaped}$`, mode: "regex", case_sensitive: true },
|
|
]) {
|
|
assert.deepEqual(
|
|
(await call("search_procedures", query))
|
|
.filter(({ address }) => address === alias.address)
|
|
.map(({ value, matched_aliases }) => ({ value, matched_aliases })),
|
|
[{ value: canonical.value, matched_aliases: [alias.value] }],
|
|
`Procedure search missed the entry alias for ${JSON.stringify(query)}`,
|
|
);
|
|
}
|
|
assert.ok(
|
|
(await call("list_procedures")).every(
|
|
(row) => row.matched_aliases === undefined,
|
|
),
|
|
"Listings must not report search matches",
|
|
);
|
|
const traced = await call("trace_feature", { query: alias.value });
|
|
assert.ok(
|
|
traced.matches.some(
|
|
({ type, address, matched_aliases }) =>
|
|
type === "procedure" &&
|
|
address === alias.address &&
|
|
matched_aliases?.includes(alias.value) === true,
|
|
),
|
|
"trace_feature missed the procedure its entry alias names",
|
|
);
|
|
const interior = names.find(
|
|
({ value }) => value === `${prefix}rea_interior`,
|
|
);
|
|
assert.ok(interior);
|
|
assert.notEqual(interior.address, alias.address);
|
|
assert.ok(
|
|
(await call("search_procedures", { pattern: interior.value })).every(
|
|
({ address, matched_aliases = [] }) =>
|
|
address !== interior.address &&
|
|
!matched_aliases.includes(interior.value),
|
|
),
|
|
"An interior label must not match procedure search",
|
|
);
|
|
assert.equal(
|
|
interior.symbol.type,
|
|
"label",
|
|
"Fixture interior control must remain an imported label",
|
|
);
|
|
assert.equal(
|
|
(await call("list_procedures")).some(
|
|
({ address }) => address === interior.address,
|
|
),
|
|
false,
|
|
"Fixture interior control must not be a Ghidra procedure entry",
|
|
);
|
|
const failure = await reject("procedure_address", {
|
|
procedure: interior.value,
|
|
});
|
|
assert.equal(failure.code, "invalid_request");
|
|
assert.match(
|
|
JSON.stringify(failure.details.issues),
|
|
/Unknown Ghidra procedure name or address/u,
|
|
);
|
|
assert.equal(
|
|
await call("procedure_address", { procedure: interior.address }),
|
|
alias.address,
|
|
"Explicit interior addresses keep their existing containment semantics",
|
|
);
|
|
const edited = await call("annotate_native_function", {
|
|
procedure: alias.value,
|
|
comment: "Selected by imported entry alias",
|
|
});
|
|
assert.equal(edited.annotations.address, alias.address);
|
|
assert.equal(
|
|
edited.annotations.comment,
|
|
"Selected by imported entry alias",
|
|
);
|
|
assert.equal(
|
|
await call("procedure_address", { procedure: alias.value }),
|
|
alias.address,
|
|
);
|
|
await call("close_binary");
|
|
const { stdout } = await exec(
|
|
process.execPath,
|
|
[
|
|
entrypoint,
|
|
"function",
|
|
path,
|
|
alias.value,
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
assert.equal(
|
|
JSON.parse(stdout).normalized_result.procedure.address,
|
|
alias.address,
|
|
);
|
|
for (const { symbol, expected } of xrefsSelectors) {
|
|
for (const selector of [
|
|
[symbol.value],
|
|
["--address", symbol.value],
|
|
[symbol.address],
|
|
]) {
|
|
const { stdout } = await exec(
|
|
process.execPath,
|
|
[
|
|
entrypoint,
|
|
"xrefs",
|
|
path,
|
|
...selector,
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
|
);
|
|
const evidence = JSON.parse(stdout);
|
|
assert.deepEqual(
|
|
evidence.normalized_result,
|
|
expected,
|
|
`CLI xrefs mismatch for ${JSON.stringify(selector)}`,
|
|
);
|
|
assert.equal(evidence.parameters.address, selector.at(-1));
|
|
}
|
|
}
|
|
await assert.rejects(
|
|
exec(
|
|
process.execPath,
|
|
[
|
|
entrypoint,
|
|
"xrefs",
|
|
path,
|
|
"rea_missing_symbol",
|
|
"--provider",
|
|
"ghidra",
|
|
"--json",
|
|
],
|
|
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
|
|
),
|
|
(error) => {
|
|
const failure = JSON.parse(error.stdout);
|
|
assert.equal(failure.code, "invalid_request");
|
|
assert.equal(failure.details.operation, "xrefs");
|
|
assert.match(
|
|
JSON.stringify(failure.details.issues),
|
|
/Unknown analyzed symbol name.*rea_missing_symbol/u,
|
|
);
|
|
return true;
|
|
},
|
|
);
|
|
assert.deepEqual(await readFile(path), bytes);
|
|
await call("open_binary", { path: target.path, provider_id: "ghidra" });
|
|
} finally {
|
|
await rm(workspace, { recursive: true, force: true });
|
|
}
|
|
}
|