diff --git a/src/cli/analysisSelector.ts b/src/cli/analysisSelector.ts index f6e550cd0f..36b71abf01 100644 --- a/src/cli/analysisSelector.ts +++ b/src/cli/analysisSelector.ts @@ -1,5 +1,17 @@ import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +/** Each separately emitted args/options schema must expose the selector choice. */ +export const requiredSelectorGuidance = (alternatives: string): string => + `Supply at least one of ${alternatives}.`; + +export const addressOrProcedureGuidance = requiredSelectorGuidance( + "the positional address or --procedure", +); + +/** Agreement details belong on the command, rather than every schema field. */ +export const equalSelectorGuidance = + "Equal values are accepted; conflicts fail."; + /** Preserve a CLI selector supplied positionally or through its named option. */ export const resolveCliAnalysisSelector = ( positional: string | undefined, diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index 3ebd9b6e0e..c7be5c0cbf 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -2,7 +2,12 @@ import { z } from "incur"; import { realpath, stat } from "node:fs/promises"; import { findAppBundleResourceRoot } from "../application/AppBundleExecutable.js"; -import { resolveCliAnalysisSelector } from "./analysisSelector.js"; +import { + addressOrProcedureGuidance, + equalSelectorGuidance, + requiredSelectorGuidance, + resolveCliAnalysisSelector, +} from "./analysisSelector.js"; import type { DirectAnalysis } from "../composition/directAnalysis.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; @@ -20,6 +25,17 @@ import { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { registerDecompileDirectoryCommand } from "./decompileDirectoryCommand.js"; +const decompileSelectorGuidance = `${addressOrProcedureGuidance} Alternatively, use --file-offset alone.`; +const namedAddressGuidance = requiredSelectorGuidance( + "the positional address or --address", +); +const querySelectorGuidance = requiredSelectorGuidance( + "the positional query or --query", +); +const patternSelectorGuidance = requiredSelectorGuidance( + "the positional pattern or --pattern", +); + /** Register provider-neutral binary overview and procedure CLI commands. */ export const registerCoreBinaryCommands = ( cli: CliInstance, @@ -158,15 +174,14 @@ const registerDecompileCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.decompile, { - description: - "Read one part of an app as code; requires a positional selector, --procedure, or --file-offset", + description: `Read one part of an app as code. ${decompileSelectorGuidance} Equal positional and --procedure values are accepted; conflicts fail.`, args: z.object({ path: z.string().describe("App or program path"), address: z .string() .min(1) .optional() - .describe("Procedure name or address"), + .describe(`Procedure name or address. ${decompileSelectorGuidance}`), }), options: z.object({ procedure: z @@ -174,14 +189,14 @@ const registerDecompileCommand = ( .min(1) .optional() .describe( - "Required selector: supply this option or the positional selector (decompile also accepts --file-offset); use --procedure= for leading dashes", + `Procedure name or address; use --procedure= for leading dashes. ${decompileSelectorGuidance}`, ), "file-offset": z .string() .regex(/^0x[0-9a-fA-F]+$/u) .optional() .describe( - "Ghidra source-file byte offset; mapped internally to an analysis address", + `Ghidra source-file byte offset; mapped internally to an analysis address. ${decompileSelectorGuidance}`, ), snapshot: snapshotSelectionOption, ...nativeOpenOptionFields, @@ -252,11 +267,14 @@ const registerXrefsCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.xrefs, { - description: - "List bounded references to an analyzed address; requires a positional selector or --address", + description: `List bounded references to an analyzed address. ${namedAddressGuidance} ${equalSelectorGuidance}`, args: z.object({ path: z.string().describe("App or program path"), - address: z.string().min(1).optional().describe("Address or symbol name"), + address: z + .string() + .min(1) + .optional() + .describe(`Address or symbol name. ${namedAddressGuidance}`), }), options: z.object({ address: z @@ -264,7 +282,7 @@ const registerXrefsCommand = ( .min(1) .optional() .describe( - "Required selector: supply this option or the positional selector; use --address= for leading dashes", + `Address or symbol name; use --address= for leading dashes. ${namedAddressGuidance}`, ), snapshot: snapshotSelectionOption, ...nativeOpenOptionFields, @@ -294,11 +312,14 @@ const registerTraceCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.trace, { - description: - "Trace a literal feature through analyzed references; requires a positional selector or --query", + description: `Trace a literal feature through analyzed references. ${querySelectorGuidance} ${equalSelectorGuidance}`, args: z.object({ path: z.string().describe("App or program path"), - query: z.string().min(1).optional().describe("Literal feature query"), + query: z + .string() + .min(1) + .optional() + .describe(`Literal feature query. ${querySelectorGuidance}`), }), options: z.object({ query: z @@ -306,7 +327,7 @@ const registerTraceCommand = ( .min(1) .optional() .describe( - "Required selector: supply this option or the positional selector; use --query= for leading dashes", + `Complete literal feature query; use --query= for leading dashes. ${querySelectorGuidance}`, ), caseSensitive: z .boolean() @@ -341,15 +362,14 @@ const registerFunctionCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.function, { - description: - "Analyze one complete function with evidence; requires a positional selector or --procedure", + description: `Analyze one complete function with evidence. ${addressOrProcedureGuidance} ${equalSelectorGuidance}`, args: z.object({ path: z.string().describe("App or program path"), address: z .string() .min(1) .optional() - .describe("Procedure name or address"), + .describe(`Procedure name or address. ${addressOrProcedureGuidance}`), }), options: z.object({ procedure: z @@ -357,7 +377,7 @@ const registerFunctionCommand = ( .min(1) .optional() .describe( - "Required selector: supply this option or the positional selector (decompile also accepts --file-offset); use --procedure= for leading dashes", + `Procedure name or address; use --procedure= for leading dashes. ${addressOrProcedureGuidance}`, ), snapshot: snapshotSelectionOption, ...nativeOpenOptionFields, @@ -387,15 +407,14 @@ const registerInstructionsCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.instructions, { - description: - "Read every raw instruction without decompiling; requires a positional selector or --procedure", + description: `Read every raw instruction without decompiling. ${addressOrProcedureGuidance} ${equalSelectorGuidance}`, args: z.object({ path: z.string().describe("App or program path"), address: z .string() .min(1) .optional() - .describe("Procedure name or address"), + .describe(`Procedure name or address. ${addressOrProcedureGuidance}`), }), options: z.object({ procedure: z @@ -403,7 +422,7 @@ const registerInstructionsCommand = ( .min(1) .optional() .describe( - "Required selector: supply this option or the positional selector (decompile also accepts --file-offset); use --procedure= for leading dashes", + `Procedure name or address; use --procedure= for leading dashes. ${addressOrProcedureGuidance}`, ), snapshot: snapshotSelectionOption, ...nativeOpenOptionFields, @@ -433,15 +452,14 @@ const registerSearchCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.search, { - description: - "Search every analyzed string or procedure name; requires a positional selector or --pattern", + description: `Search every analyzed string or procedure name. ${patternSelectorGuidance} ${equalSelectorGuidance}`, args: z.object({ path: z.string().describe("App or program path"), pattern: z .string() .min(1) .optional() - .describe("Literal text or regex pattern"), + .describe(`Literal text or regex pattern. ${patternSelectorGuidance}`), }), options: z.object({ pattern: z @@ -449,7 +467,7 @@ const registerSearchCommand = ( .min(1) .optional() .describe( - "Required selector: supply this option or the positional selector; use --pattern= for leading dashes", + `Complete literal text or regex pattern; use --pattern= for leading dashes. ${patternSelectorGuidance}`, ), kind: z .enum(["strings", "procedures"]) diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index 1a9bb3a653..6d033aad27 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -1,6 +1,10 @@ import { nativeDispatchMetadataInputSchema } from "../domain/native/objcSwiftMetadata.js"; import { z } from "incur"; -import { resolveCliAnalysisSelector } from "./analysisSelector.js"; +import { + addressOrProcedureGuidance, + equalSelectorGuidance, + resolveCliAnalysisSelector, +} from "./analysisSelector.js"; import type { DirectAnalysis } from "../composition/directAnalysis.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -357,17 +361,20 @@ const registerNativeApiCommand = ( runDirectAnalysis: DirectAnalysis["runDirectAnalysis"], ): void => { cli.command(CLI_COMMANDS.inspectNativeApi, { - description: "Reconstruct one native function API boundary with evidence", + description: `Reconstruct one native function API boundary with evidence. ${addressOrProcedureGuidance} ${equalSelectorGuidance}`, args: z.object({ path: z.string().describe("App or program path"), - address: z.string().optional().describe("Procedure name or address"), + address: z + .string() + .optional() + .describe(`Procedure name or address. ${addressOrProcedureGuidance}`), }), options: z.object({ procedure: z .string() .optional() .describe( - "Procedure selector; use this option for symbols beginning with a dash", + `Procedure selector; use this option for symbols beginning with a dash. ${addressOrProcedureGuidance}`, ), snapshot: snapshotSelectionOption, ...nativeOpenOptionFields, diff --git a/src/cli/fridaCommands.ts b/src/cli/fridaCommands.ts index eddf565319..bb35af72f3 100644 --- a/src/cli/fridaCommands.ts +++ b/src/cli/fridaCommands.ts @@ -18,6 +18,10 @@ import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; +/** Script sources stay optional in the options schema; exactly one is required. */ +const exactlyOneScriptSource = + "Provide exactly one of --script or --script-file."; + /** Register Frida discovery and one-shot instrumentation CLI operations. */ export const registerFridaCommands = ( cli: CliInstance, @@ -136,8 +140,7 @@ export const registerFridaCommands = ( ), }); cli.command(CLI_COMMANDS.instrumentWithFrida, { - description: - "Run one Frida script against a selected process and detach; requires exactly one of --script or --script-file", + description: `Run one Frida script against a selected process and detach. ${exactlyOneScriptSource}`, args: z.object({ mode: z .enum(["attach", "spawn"]) @@ -157,14 +160,14 @@ export const registerFridaCommands = ( script: z .string() .optional() - .describe( - "Inline JavaScript to load; supply exactly one of --script or --script-file", - ), + .describe(`Inline JavaScript to load. ${exactlyOneScriptSource}`), scriptFile: z .string() .min(1) .optional() - .describe("Path to JavaScript source file"), + .describe( + `Path to JavaScript source file. ${exactlyOneScriptSource}`, + ), argument: z .array(z.string()) .optional() diff --git a/tests/boundary/cli/analysisSelectorCli.test.ts b/tests/boundary/cli/analysisSelectorCli.test.ts index 74b9987179..a639f724da 100644 --- a/tests/boundary/cli/analysisSelectorCli.test.ts +++ b/tests/boundary/cli/analysisSelectorCli.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "vitest"; -import { cliTest } from "../../support/cli/cliFixture.js"; +import { cliTest, runFileCli } from "../../support/cli/cliFixture.js"; const selectors = [ ["function", "procedure", "analyze_function"], @@ -45,10 +45,10 @@ 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 [command, option, operation] of selectors) { + cliTest( + `rejects missing and conflicting ${command} selectors before opening a target`, + async ({ cli }) => { for (const [selection, reason] of [ [[], "missing_argument"], [["first", `--${option}=-second`], "invalid_value"], @@ -69,14 +69,11 @@ describe("CLI analysis selector boundaries", () => { details: { operation, issues: [{ path: [option], reason }] }, }); } - } - }, - ); - - cliTest( - "keeps named dash values out of global-option parsing", - async ({ cli }) => { - for (const [command, option] of selectors) { + }, + ); + cliTest( + `keeps named ${command} dash values out of global-option parsing`, + async ({ cli }) => { const result = await cli.run({ arguments: [ command, @@ -89,6 +86,35 @@ describe("CLI analysis selector boundaries", () => { }); expect(result.exitCode).not.toBe(0); expect(result.json).toMatchObject({ code: "target_unavailable" }); + }, + ); + } + + cliTest( + "admits positional, named, and identical selectors before opening a missing target", + async ({ cli }) => { + for (const [command, option] of selectors) { + for (const selection of [ + ["selector"], + [`--${option}=selector`], + ["selector", `--${option}=selector`], + ]) { + const label = `${command} ${selection.join(" ")}`; + const result = await runFileCli(cli, { + arguments: [ + command, + "does-not-exist", + ...selection, + "--format", + "json", + ], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.exitCode, label).not.toBe(0); + expect(result.json, label).toMatchObject({ + code: "target_unavailable", + }); + } } }, ); diff --git a/tests/boundary/cli/cliInputSchemaRequired.test.ts b/tests/boundary/cli/cliInputSchemaRequired.test.ts index e87c849ea8..a64fc24851 100644 --- a/tests/boundary/cli/cliInputSchemaRequired.test.ts +++ b/tests/boundary/cli/cliInputSchemaRequired.test.ts @@ -1,5 +1,6 @@ import { Ajv2020 } from "ajv/dist/2020.js"; import { Cli, z } from "incur"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { createCli } from "../../../src/cli.js"; @@ -370,4 +371,345 @@ describe("reference-source outline-depth input schema", () => { expect(validate({ outlineDepth: 2 })).toBe(true); }); }); -import { resolve } from "node:path"; + +const atLeastOneSelector = (alternatives: string): string => + `Supply at least one of ${alternatives}.`; +const ADDRESS_OR_PROCEDURE = atLeastOneSelector( + "the positional address or --procedure", +); +const DECOMPILE_SELECTORS = `${ADDRESS_OR_PROCEDURE} Alternatively, use --file-offset alone.`; +const NAMED_ADDRESS = atLeastOneSelector("the positional address or --address"); +const QUERY_SELECTOR = atLeastOneSelector("the positional query or --query"); +const PATTERN_SELECTOR = atLeastOneSelector( + "the positional pattern or --pattern", +); +const EXACTLY_ONE_SCRIPT = "Provide exactly one of --script or --script-file."; +const TARGET = "target.bin"; + +interface SelectorRequest { + readonly args: Record; + readonly options: Record; +} + +interface SelectorField { + readonly group: "args" | "options"; + readonly name: string; + readonly preserved?: readonly string[]; +} + +interface SelectorDiscovery { + readonly command: string; + readonly guidance: string; + readonly choice: "at least one" | "exactly one"; + readonly fields: readonly SelectorField[]; + /** Each side is valid alone, including when the other group omits its selector. */ + readonly representable: readonly SelectorRequest[]; + /** Cross-group omission stays valid because the schemas are separate. */ + readonly omitted: SelectorRequest; + readonly rejected: readonly { + readonly args?: Record; + readonly options?: Record; + }[]; +} + +const selectorDiscovery: readonly SelectorDiscovery[] = [ + { + command: "inspect-native-api", + guidance: ADDRESS_OR_PROCEDURE, + choice: "at least one", + fields: [ + { group: "args", name: "address" }, + { + group: "options", + name: "procedure", + preserved: ["symbols beginning with a dash"], + }, + ], + representable: [ + { args: { path: TARGET, address: "main" }, options: {} }, + { args: { path: TARGET }, options: { procedure: "--help" } }, + { + args: { path: TARGET, address: "main" }, + options: { procedure: "main" }, + }, + ], + omitted: { args: { path: TARGET }, options: {} }, + rejected: [ + { args: { path: TARGET, address: 1 } }, + { options: { procedure: 1 } }, + ], + }, + { + command: "function", + guidance: ADDRESS_OR_PROCEDURE, + choice: "at least one", + fields: [ + { group: "args", name: "address" }, + { + group: "options", + name: "procedure", + preserved: ["use --procedure= for leading dashes"], + }, + ], + representable: [ + { args: { path: TARGET, address: "main" }, options: {} }, + { args: { path: TARGET }, options: { procedure: "--help" } }, + { + args: { path: TARGET, address: "main" }, + options: { procedure: "main" }, + }, + ], + omitted: { args: { path: TARGET }, options: {} }, + rejected: [ + { args: { path: TARGET, address: "" } }, + { options: { procedure: "" } }, + ], + }, + { + command: "instructions", + guidance: ADDRESS_OR_PROCEDURE, + choice: "at least one", + fields: [ + { group: "args", name: "address" }, + { + group: "options", + name: "procedure", + preserved: ["use --procedure= for leading dashes"], + }, + ], + representable: [ + { args: { path: TARGET, address: "main" }, options: {} }, + { args: { path: TARGET }, options: { procedure: "--help" } }, + { + args: { path: TARGET, address: "main" }, + options: { procedure: "main" }, + }, + ], + omitted: { args: { path: TARGET }, options: {} }, + rejected: [ + { args: { path: TARGET, address: "" } }, + { options: { procedure: "" } }, + ], + }, + { + command: "decompile", + guidance: DECOMPILE_SELECTORS, + choice: "at least one", + fields: [ + { group: "args", name: "address" }, + { + group: "options", + name: "procedure", + preserved: ["use --procedure= for leading dashes"], + }, + { + group: "options", + name: "file-offset", + preserved: [ + "Ghidra source-file byte offset", + "mapped internally to an analysis address", + ], + }, + ], + representable: [ + { args: { path: TARGET, address: "0x140001010" }, options: {} }, + { args: { path: TARGET }, options: { procedure: "--help" } }, + { args: { path: TARGET }, options: { "file-offset": "0x210" } }, + { + args: { path: TARGET, address: "0x140001010" }, + options: { procedure: "0x140001010" }, + }, + ], + omitted: { args: { path: TARGET }, options: {} }, + rejected: [ + { args: { path: TARGET, address: "" } }, + { options: { procedure: "" } }, + { options: { "file-offset": "210" } }, + { options: { "file-offset": "0x" } }, + ], + }, + { + command: "xrefs", + guidance: NAMED_ADDRESS, + choice: "at least one", + fields: [ + { group: "args", name: "address" }, + { + group: "options", + name: "address", + preserved: ["use --address= for leading dashes"], + }, + ], + representable: [ + { args: { path: TARGET, address: "main" }, options: {} }, + { args: { path: TARGET }, options: { address: "--help" } }, + { + args: { path: TARGET, address: "main" }, + options: { address: "main" }, + }, + ], + omitted: { args: { path: TARGET }, options: {} }, + rejected: [ + { args: { path: TARGET, address: "" } }, + { options: { address: "" } }, + ], + }, + { + command: "trace", + guidance: QUERY_SELECTOR, + choice: "at least one", + fields: [ + { group: "args", name: "query" }, + { + group: "options", + name: "query", + preserved: ["use --query= for leading dashes"], + }, + ], + representable: [ + { args: { path: TARGET, query: "feature" }, options: {} }, + { args: { path: TARGET }, options: { query: "--help" } }, + { + args: { path: TARGET, query: "feature" }, + options: { query: "feature" }, + }, + ], + omitted: { args: { path: TARGET }, options: {} }, + rejected: [ + { args: { path: TARGET, query: "" } }, + { options: { query: "" } }, + ], + }, + { + command: "search", + guidance: PATTERN_SELECTOR, + choice: "at least one", + fields: [ + { group: "args", name: "pattern" }, + { + group: "options", + name: "pattern", + preserved: ["use --pattern= for leading dashes"], + }, + ], + representable: [ + { args: { path: TARGET, pattern: "text" }, options: {} }, + { args: { path: TARGET }, options: { pattern: "--help" } }, + { + args: { path: TARGET, pattern: "text" }, + options: { pattern: "text" }, + }, + ], + omitted: { args: { path: TARGET }, options: {} }, + rejected: [ + { args: { path: TARGET, pattern: "" } }, + { options: { pattern: "" } }, + ], + }, + { + command: "instrument-with-frida", + guidance: EXACTLY_ONE_SCRIPT, + choice: "exactly one", + fields: [ + { group: "options", name: "script" }, + { group: "options", name: "scriptFile" }, + ], + representable: [ + { + args: { mode: "attach", target: "71" }, + options: { script: "send(1)" }, + }, + { + args: { mode: "attach", target: "71" }, + options: { scriptFile: "source.js" }, + }, + { + args: { mode: "attach", target: "71" }, + options: { script: "send(1)", scriptFile: "source.js" }, + }, + ], + omitted: { args: { mode: "attach", target: "71" }, options: {} }, + rejected: [ + { args: { mode: "attach", target: "" } }, + { args: { mode: "debug", target: "71" } }, + { options: { scriptFile: "" } }, + ], + }, +]; + +const propertyDescription = (property: unknown, label: string): string => { + expect(property, label).toMatchObject({ + description: expect.any(String), + }); + if ( + typeof property !== "object" || + property === null || + !("description" in property) || + typeof property.description !== "string" + ) + throw new Error(`${label} description was not on the schema property`); + return property.description; +}; + +describe("required CLI selector discovery", () => { + it.each(selectorDiscovery)( + "$command names every permitted selector alternative in schema, help, and the full LLM manifest", + async (entry) => { + const cli = createCli({}); + const advertised = await advertisedSchema(cli, entry.command); + const otherChoice = + entry.choice === "at least one" ? "exactly one" : "at least one"; + for (const field of entry.fields) { + const label = `${entry.command} ${field.group}.${field.name}`; + const description = propertyDescription( + advertised[field.group]?.properties[field.name], + label, + ); + expect(description, label).toContain(entry.guidance); + expect(description, label).toContain(entry.choice); + expect(description, label).not.toContain(otherChoice); + for (const preserved of field.preserved ?? []) + expect(description, label).toContain(preserved); + expect(advertised[field.group]?.required ?? [], label).not.toContain( + field.name, + ); + } + + const help = await serve(cli, [entry.command, "--help"]); + const manifest = await serve(cli, [entry.command, "--llms-full"]); + expect(help.exitCode, entry.command).toBe(0); + expect(manifest.exitCode, entry.command).toBe(0); + for (const surface of [help.stdout, manifest.stdout]) { + expect(surface).toContain(entry.guidance); + expect(surface).toContain(entry.choice); + expect(surface).not.toContain(otherChoice); + for (const field of entry.fields) + for (const preserved of field.preserved ?? []) + expect(surface).toContain(preserved); + } + + const ajv = new Ajv2020({ strict: false }); + expect(advertised.args, entry.command).toBeDefined(); + expect(advertised.options, entry.command).toBeDefined(); + const validateArgs = ajv.compile(advertised.args ?? {}); + const validateOptions = ajv.compile(advertised.options ?? {}); + expect(validateArgs({}), entry.command).toBe(false); + for (const request of [...entry.representable, entry.omitted]) { + expect( + validateArgs(request.args), + `${entry.command} args ${JSON.stringify(validateArgs.errors)}`, + ).toBe(true); + expect( + validateOptions(request.options), + `${entry.command} options ${JSON.stringify(validateOptions.errors)}`, + ).toBe(true); + } + for (const request of entry.rejected) { + if (request.args !== undefined) + expect(validateArgs(request.args), entry.command).toBe(false); + if (request.options !== undefined) + expect(validateOptions(request.options), entry.command).toBe(false); + } + }, + ); +}); diff --git a/tests/boundary/cli/fridaCli.test.ts b/tests/boundary/cli/fridaCli.test.ts index 3907869f05..efdfac275e 100644 --- a/tests/boundary/cli/fridaCli.test.ts +++ b/tests/boundary/cli/fridaCli.test.ts @@ -493,6 +493,11 @@ it.each([ "script", "Provide exactly one of --script or --script-file.", ], + [ + ["attach", "71", "--script", "send(1)", "--script-file", "source.js"], + "script", + "Provide exactly one of --script or --script-file.", + ], [ ["attach", "71", "--script", "send(1)", "--argument", "ignored"], "argument", @@ -526,3 +531,55 @@ it.each([ expect(starts).toBe(0); }, ); + +it("advances each Frida script source to the attach target error without attaching or spawning", async () => { + const directory = await mkdtemp(join(tmpdir(), "rea-frida-script-source-")); + const scriptPath = join(directory, "source.js"); + try { + await writeFile(scriptPath, "send(1);\n"); + for (const scriptArguments of [ + ["--script", "send(1)"], + ["--script-file", scriptPath], + ]) { + let starts = 0; + const provider = { + async startSession() { + starts += 1; + throw new Error("Frida must not attach or spawn for an invalid PID"); + }, + } as unknown as FridaInstrumentationPort; + let stdout = ""; + await createCli({}, undefined, provider).serve( + [ + "instrument-with-frida", + "attach", + "abc", + ...scriptArguments, + "--json", + ], + { + env: {}, + exit: () => undefined, + stdout: (text) => { + stdout += text; + }, + }, + ); + expect(JSON.parse(stdout)).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["target"], + reason: "invalid_value", + message: "attach mode needs a positive integer process ID.", + }, + ], + }, + }); + expect(starts).toBe(0); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +});