fix(cli): accept exact native names through explicit selectors
This commit is contained in:
parent
2a4979b7cd
commit
c3136064c7
|
|
@ -83,6 +83,18 @@ complete observation.
|
|||
partial coverage. Other providers retain the
|
||||
existing symbol-based inventory with its narrower coverage.
|
||||
|
||||
## Exact CLI selectors
|
||||
|
||||
`function`, `instructions` and `decompile` accept `--procedure=<name-or-address>`;
|
||||
`xrefs` accepts `--address=<name-or-address>`. `search` accepts `--pattern=<text>`
|
||||
and `trace` accepts `--query=<text>`. These alternatives preserve names and text
|
||||
that begin with a dash, including Objective-C names such as
|
||||
`rea function ./app '--procedure=-[REAWidget delegate]' --provider hopper --format json`.
|
||||
Use the equals form when the value resembles a CLI flag, for example
|
||||
`rea trace ./app --query=--help --provider hopper --format json`.
|
||||
The existing positional forms remain available. Missing selectors and conflicting
|
||||
positional/named values fail before analysis starts; identical selections agree.
|
||||
|
||||
## Native instruction, call and type primitives
|
||||
|
||||
Ghidra supplies three exact-object operations:
|
||||
|
|
|
|||
|
|
@ -214,6 +214,9 @@ Objective-C fixture is present, the lane also verifies native UTF-16 string obje
|
|||
and their inferred encodings against their actual bytes. Native terminal calls
|
||||
are checked across reference, instruction, assembly, block-range and procedure-length
|
||||
projections; block endpoints are normalized using actual native membership.
|
||||
Exact Objective-C names also exercise named CLI selectors for function, instruction,
|
||||
decompilation, reference and search operations. A literal `--help` trace query proves
|
||||
that selector data is preserved independently of global CLI flags.
|
||||
Unmapped annotation destinations and
|
||||
oversized later batch names fail before any earlier edit is applied.
|
||||
The Linux demo lane remains a separate `verify:hopper:linux` command.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { promisify } from "node:util";
|
|||
import Ajv from "ajv/dist/2020.js";
|
||||
import addFormats from "ajv-formats";
|
||||
import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs";
|
||||
import { verifyHopperCliSelectors } from "./real-hopper-cli-selectors.mjs";
|
||||
import { verifyHopperNavigationAndText } from "./real-hopper-navigation.mjs";
|
||||
import { verifyHopperWorkflows } from "./real-hopper-workflows.mjs";
|
||||
import {
|
||||
|
|
@ -395,6 +396,7 @@ export async function verifyHopperLifecycleAndCli(client, options, targets) {
|
|||
expectedFunction,
|
||||
);
|
||||
let cliTerminalFunctionParity = null;
|
||||
let cliNamedSelectorParity = null;
|
||||
if (targets.unicode !== undefined) {
|
||||
const objc = join(directory, `objc-${suffix}`);
|
||||
await copyFile(targets.unicode, objc);
|
||||
|
|
@ -420,6 +422,13 @@ export async function verifyHopperLifecycleAndCli(client, options, targets) {
|
|||
]);
|
||||
assert.deepEqual(JSON.parse(analyzed.stdout).normalized_result, expected);
|
||||
cliTerminalFunctionParity = true;
|
||||
await call("open_binary", { path: objc });
|
||||
cliNamedSelectorParity = await verifyHopperCliSelectors(
|
||||
call,
|
||||
runCli,
|
||||
dispatcher,
|
||||
objc,
|
||||
);
|
||||
}
|
||||
let failure;
|
||||
try {
|
||||
|
|
@ -452,6 +461,7 @@ export async function verifyHopperLifecycleAndCli(client, options, targets) {
|
|||
cliLiteralTraceParity: true,
|
||||
cliFunctionDossierParity: true,
|
||||
cliTerminalFunctionParity,
|
||||
cliNamedSelectorParity,
|
||||
callerCancellationRecovered: true,
|
||||
closedDocumentAbsent: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import assert from "node:assert/strict";
|
||||
|
||||
/** Compare exact native symbol and literal selectors through MCP and the real CLI. */
|
||||
export async function verifyHopperCliSelectors(call, runCli, dispatcher, path) {
|
||||
const procedures = await call("list_procedures");
|
||||
const delegate = procedures.find(
|
||||
(item) => item.value === "-[REAWidget delegate]",
|
||||
);
|
||||
assert.ok(delegate, "Objective-C fixture omitted its tail-call method");
|
||||
const cases = [
|
||||
{
|
||||
command: "function",
|
||||
option: "procedure",
|
||||
operation: "analyze_function",
|
||||
parameters: { procedure: delegate.value },
|
||||
},
|
||||
{
|
||||
command: "instructions",
|
||||
option: "procedure",
|
||||
operation: "read_function_instructions",
|
||||
parameters: { procedure: delegate.value },
|
||||
},
|
||||
{
|
||||
command: "decompile",
|
||||
option: "procedure",
|
||||
operation: "procedure_pseudo_code",
|
||||
parameters: { procedure: delegate.value },
|
||||
},
|
||||
{
|
||||
command: "xrefs",
|
||||
option: "address",
|
||||
operation: "xrefs",
|
||||
parameters: { address: delegate.value },
|
||||
},
|
||||
{
|
||||
command: "search",
|
||||
option: "pattern",
|
||||
operation: "search_procedures",
|
||||
parameters: {
|
||||
pattern: delegate.value,
|
||||
mode: "literal",
|
||||
case_sensitive: true,
|
||||
},
|
||||
flags: ["--kind", "procedures", "--case-sensitive"],
|
||||
},
|
||||
{
|
||||
command: "trace",
|
||||
option: "query",
|
||||
operation: "trace_feature",
|
||||
parameters: { query: "--help" },
|
||||
},
|
||||
];
|
||||
const expected = [];
|
||||
for (const item of cases)
|
||||
expected.push(await call(item.operation, item.parameters));
|
||||
await call("close_binary");
|
||||
for (let i = 0; i < cases.length; i++) {
|
||||
const item = cases[i];
|
||||
const selector = item.parameters[item.option];
|
||||
const { stdout } = await runCli([
|
||||
dispatcher,
|
||||
item.command,
|
||||
path,
|
||||
`--${item.option}=${selector}`,
|
||||
...(item.flags ?? []),
|
||||
"--provider",
|
||||
"hopper",
|
||||
"--format",
|
||||
"json",
|
||||
]);
|
||||
const evidence = JSON.parse(stdout);
|
||||
assert.deepEqual(
|
||||
evidence.normalized_result,
|
||||
expected[i],
|
||||
`${item.command} named selector differs from MCP`,
|
||||
);
|
||||
assert.equal(
|
||||
evidence.parameters[item.option],
|
||||
selector,
|
||||
`${item.command} altered its selector`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
commands: cases.map(({ command }) => command),
|
||||
exactObjectiveCNames: true,
|
||||
literalGlobalFlagPreserved: true,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
/** Preserve a CLI selector supplied positionally or through its named option. */
|
||||
export const resolveCliAnalysisSelector = (
|
||||
positional: string | undefined,
|
||||
named: string | undefined,
|
||||
option: string,
|
||||
operation: string,
|
||||
): string => {
|
||||
if (positional !== undefined && named !== undefined && positional !== named)
|
||||
throw new AnalysisInputError(operation, undefined, [
|
||||
{
|
||||
path: [option],
|
||||
reason: "invalid_value",
|
||||
message: `The positional selector and --${option} select different objects; supply one selector.`,
|
||||
},
|
||||
]);
|
||||
const selected = named ?? positional;
|
||||
if (selected === undefined)
|
||||
throw new AnalysisInputError(operation, undefined, [
|
||||
{
|
||||
path: [option],
|
||||
reason: "missing_argument",
|
||||
message: `Supply a positional selector or --${option}=<value>. Use the named option for values beginning with a dash.`,
|
||||
expected: "string",
|
||||
},
|
||||
]);
|
||||
return selected;
|
||||
};
|
||||
|
|
@ -2,6 +2,7 @@ import { z } from "incur";
|
|||
import { stat } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { resolveCliAnalysisSelector } from "./analysisSelector.js";
|
||||
import { runDirectAnalysis } from "../composition/directAnalysis.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
|
|
@ -25,6 +26,7 @@ export const registerCoreBinaryCommands = (
|
|||
resultOutput?: CliResultOutput,
|
||||
): void => {
|
||||
registerOverviewCommands(cli, logger, resultOutput);
|
||||
registerDecompileCommand(cli, logger);
|
||||
registerFunctionCommand(cli, logger);
|
||||
registerInstructionsCommand(cli, logger);
|
||||
registerSearchCommand(cli, logger);
|
||||
|
|
@ -85,13 +87,27 @@ const registerOverviewCommands = (
|
|||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const registerDecompileCommand = (cli: CliInstance, logger: Logger): void => {
|
||||
cli.command(CLI_COMMANDS.decompile, {
|
||||
description: "Read one part of an app as code",
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
address: z.string().describe("Procedure address"),
|
||||
address: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Procedure name or address"),
|
||||
}),
|
||||
options: z.object({
|
||||
procedure: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Procedure name or address; use --procedure=<value> for leading dashes",
|
||||
),
|
||||
snapshot: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
|
@ -105,7 +121,14 @@ const registerOverviewCommands = (
|
|||
runDirectAnalysis(
|
||||
args.path,
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: args.address },
|
||||
{
|
||||
procedure: resolveCliAnalysisSelector(
|
||||
args.address,
|
||||
options.procedure,
|
||||
"procedure",
|
||||
"procedure_pseudo_code",
|
||||
),
|
||||
},
|
||||
directAnalysisOptions(
|
||||
logger,
|
||||
options.snapshot,
|
||||
|
|
@ -169,9 +192,16 @@ const registerXrefsCommand = (cli: CliInstance, logger: Logger): void => {
|
|||
description: "List bounded references to an analyzed address",
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
address: z.string().describe("Hexadecimal address"),
|
||||
address: z.string().min(1).optional().describe("Address or symbol name"),
|
||||
}),
|
||||
options: z.object({
|
||||
address: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Address or symbol name; use --address=<value> for leading dashes",
|
||||
),
|
||||
snapshot: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
|
@ -185,7 +215,14 @@ const registerXrefsCommand = (cli: CliInstance, logger: Logger): void => {
|
|||
runDirectAnalysis(
|
||||
args.path,
|
||||
"xrefs",
|
||||
{ address: args.address },
|
||||
{
|
||||
address: resolveCliAnalysisSelector(
|
||||
args.address,
|
||||
options.address,
|
||||
"address",
|
||||
"xrefs",
|
||||
),
|
||||
},
|
||||
directAnalysisOptions(
|
||||
logger,
|
||||
options.snapshot,
|
||||
|
|
@ -202,9 +239,16 @@ const registerTraceCommand = (cli: CliInstance, logger: Logger): void => {
|
|||
description: "Trace a literal feature through analyzed references",
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
query: z.string().min(1).describe("Literal feature query"),
|
||||
query: z.string().min(1).optional().describe("Literal feature query"),
|
||||
}),
|
||||
options: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Complete literal feature query; use --query=<value> for leading dashes",
|
||||
),
|
||||
caseSensitive: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
|
|
@ -226,7 +270,12 @@ const registerTraceCommand = (cli: CliInstance, logger: Logger): void => {
|
|||
args.path,
|
||||
"trace_feature",
|
||||
{
|
||||
query: args.query,
|
||||
query: resolveCliAnalysisSelector(
|
||||
args.query,
|
||||
options.query,
|
||||
"query",
|
||||
"trace_feature",
|
||||
),
|
||||
case_sensitive: options.caseSensitive,
|
||||
},
|
||||
directAnalysisOptions(
|
||||
|
|
@ -245,9 +294,20 @@ const registerFunctionCommand = (cli: CliInstance, logger: Logger): void => {
|
|||
description: "Analyze one complete function with evidence",
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
address: z.string().describe("Procedure name or address"),
|
||||
address: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Procedure name or address"),
|
||||
}),
|
||||
options: z.object({
|
||||
procedure: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Procedure name or address; use --procedure=<value> for leading dashes",
|
||||
),
|
||||
snapshot: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
|
@ -261,7 +321,14 @@ const registerFunctionCommand = (cli: CliInstance, logger: Logger): void => {
|
|||
runDirectAnalysis(
|
||||
args.path,
|
||||
"analyze_function",
|
||||
{ procedure: args.address },
|
||||
{
|
||||
procedure: resolveCliAnalysisSelector(
|
||||
args.address,
|
||||
options.procedure,
|
||||
"procedure",
|
||||
"analyze_function",
|
||||
),
|
||||
},
|
||||
directAnalysisOptions(
|
||||
logger,
|
||||
options.snapshot,
|
||||
|
|
@ -281,9 +348,20 @@ const registerInstructionsCommand = (
|
|||
description: "Read every raw instruction without decompiling",
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
address: z.string().describe("Procedure name or address"),
|
||||
address: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Procedure name or address"),
|
||||
}),
|
||||
options: z.object({
|
||||
procedure: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Procedure name or address; use --procedure=<value> for leading dashes",
|
||||
),
|
||||
snapshot: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
|
@ -298,7 +376,12 @@ const registerInstructionsCommand = (
|
|||
args.path,
|
||||
"read_function_instructions",
|
||||
{
|
||||
procedure: args.address,
|
||||
procedure: resolveCliAnalysisSelector(
|
||||
args.address,
|
||||
options.procedure,
|
||||
"procedure",
|
||||
"read_function_instructions",
|
||||
),
|
||||
},
|
||||
directAnalysisOptions(
|
||||
logger,
|
||||
|
|
@ -316,9 +399,20 @@ const registerSearchCommand = (cli: CliInstance, logger: Logger): void => {
|
|||
description: "Search every analyzed string or procedure name",
|
||||
args: z.object({
|
||||
path: z.string().describe("App or program path"),
|
||||
pattern: z.string().min(1).describe("Literal text or regex pattern"),
|
||||
pattern: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Literal text or regex pattern"),
|
||||
}),
|
||||
options: z.object({
|
||||
pattern: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Complete literal text or regex pattern; use --pattern=<value> for leading dashes",
|
||||
),
|
||||
kind: z
|
||||
.enum(["strings", "procedures"])
|
||||
.default("strings")
|
||||
|
|
@ -346,7 +440,14 @@ const registerSearchCommand = (cli: CliInstance, logger: Logger): void => {
|
|||
args.path,
|
||||
options.kind === "strings" ? "search_strings" : "search_procedures",
|
||||
{
|
||||
pattern: args.pattern,
|
||||
pattern: resolveCliAnalysisSelector(
|
||||
args.pattern,
|
||||
options.pattern,
|
||||
"pattern",
|
||||
options.kind === "strings"
|
||||
? "search_strings"
|
||||
: "search_procedures",
|
||||
),
|
||||
mode: options.mode,
|
||||
case_sensitive: options.caseSensitive,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect } from "vitest";
|
||||
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const selectors = [
|
||||
["function", "procedure", "analyze_function"],
|
||||
["instructions", "procedure", "read_function_instructions"],
|
||||
["decompile", "procedure", "procedure_pseudo_code"],
|
||||
["xrefs", "address", "xrefs"],
|
||||
["search", "pattern", "search_strings"],
|
||||
["trace", "query", "trace_feature"],
|
||||
] as const;
|
||||
|
||||
describe("CLI analysis selector boundaries", () => {
|
||||
cliTest(
|
||||
"rejects missing and conflicting selectors before opening a target",
|
||||
async ({ cli }) => {
|
||||
for (const [command, option, operation] of selectors) {
|
||||
for (const [selection, reason] of [
|
||||
[[], "missing_argument"],
|
||||
[["first", `--${option}=-second`], "invalid_value"],
|
||||
] as const) {
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
command,
|
||||
"does-not-exist",
|
||||
...selection,
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: { operation, issues: [{ path: [option], reason }] },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"keeps named dash values out of global-option parsing",
|
||||
async ({ cli }) => {
|
||||
for (const [command, option] of selectors) {
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
command,
|
||||
"does-not-exist",
|
||||
`--${option}=--help`,
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
environment: { REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.json).toMatchObject({ code: "target_unavailable" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue