diff --git a/docs/native-investigation.md b/docs/native-investigation.md index e4fdf9a5a..e23b94a4f 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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=`; +`xrefs` accepts `--address=`. `search` accepts `--pattern=` +and `trace` accepts `--query=`. 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: diff --git a/docs/testing.md b/docs/testing.md index 224af2b91..2635552f1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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. diff --git a/scripts/lib/real-hopper-boundaries.mjs b/scripts/lib/real-hopper-boundaries.mjs index 8414cbc45..b094cf412 100644 --- a/scripts/lib/real-hopper-boundaries.mjs +++ b/scripts/lib/real-hopper-boundaries.mjs @@ -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, }; diff --git a/scripts/lib/real-hopper-cli-selectors.mjs b/scripts/lib/real-hopper-cli-selectors.mjs new file mode 100644 index 000000000..7bfa4d127 --- /dev/null +++ b/scripts/lib/real-hopper-cli-selectors.mjs @@ -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, + }; +} diff --git a/src/cli/analysisSelector.ts b/src/cli/analysisSelector.ts new file mode 100644 index 000000000..f6e550cd0 --- /dev/null +++ b/src/cli/analysisSelector.ts @@ -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}=. Use the named option for values beginning with a dash.`, + expected: "string", + }, + ]); + return selected; +}; diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index 441d0e147..718f55d6d 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -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= 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= 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= 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= 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= 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= 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, }, diff --git a/tests/boundary/cli/analysisSelectorCli.test.ts b/tests/boundary/cli/analysisSelectorCli.test.ts new file mode 100644 index 000000000..404cad6a2 --- /dev/null +++ b/tests/boundary/cli/analysisSelectorCli.test.ts @@ -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" }); + } + }, + ); +});