diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index e90a6e59ca..f9cf5206ef 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -174,7 +174,9 @@ const registerOverviewCommands = ( cli.command(CLI_COMMANDS.inspect, { description: "Inspect an app overview with evidence", args: z.object({ - path: z.string().describe("App, program, or analysis database path"), + path: localPathStringSchema.describe( + "App, program, or analysis database path", + ), }), options: overviewOptions, run: ({ args, options }) => @@ -197,7 +199,7 @@ const registerDecompileCommand = ( cli.command(CLI_COMMANDS.decompile, { 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"), + path: localPathStringSchema.describe("App or program path"), address: z .string() .min(1) @@ -290,7 +292,7 @@ const registerXrefsCommand = ( cli.command(CLI_COMMANDS.xrefs, { description: `List bounded references to an analyzed address. ${namedAddressGuidance} ${equalSelectorGuidance}`, args: z.object({ - path: z.string().describe("App or program path"), + path: localPathStringSchema.describe("App or program path"), address: z .string() .min(1) @@ -335,7 +337,7 @@ const registerTraceCommand = ( cli.command(CLI_COMMANDS.trace, { description: `Trace a literal feature through analyzed references. ${querySelectorGuidance} ${equalSelectorGuidance}`, args: z.object({ - path: z.string().describe("App or program path"), + path: localPathStringSchema.describe("App or program path"), query: z .string() .min(1) @@ -385,7 +387,7 @@ const registerFunctionCommand = ( cli.command(CLI_COMMANDS.function, { description: `Analyze one complete function with evidence. ${addressOrProcedureGuidance} ${equalSelectorGuidance}`, args: z.object({ - path: z.string().describe("App or program path"), + path: localPathStringSchema.describe("App or program path"), address: z .string() .min(1) @@ -444,7 +446,7 @@ const registerInstructionsCommand = ( cli.command(CLI_COMMANDS.instructions, { description: `Read every raw instruction without decompiling. ${addressOrProcedureGuidance} ${equalSelectorGuidance}`, args: z.object({ - path: z.string().describe("App or program path"), + path: localPathStringSchema.describe("App or program path"), address: z .string() .min(1) @@ -489,7 +491,7 @@ const registerSearchCommand = ( cli.command(CLI_COMMANDS.search, { description: `Search every analyzed string or procedure name. ${patternSelectorGuidance} ${equalSelectorGuidance}`, args: z.object({ - path: z.string().describe("App or program path"), + path: localPathStringSchema.describe("App or program path"), pattern: z .string() .min(1) diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index 0290cc6144..d438e4bb39 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -8,6 +8,7 @@ import { MAX_BYTE_SEARCH_MATCHES, } from "../domain/native/bytePattern.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import { localPathStringSchema } from "../domain/localPath.js"; import { addressOrProcedureGuidance, @@ -47,7 +48,7 @@ export const registerCoreNativeCommands = ( cli.command(CLI_COMMANDS.searchBytes, { description: "Find masked byte patterns in initialized provider memory", args: z.object({ - path: z.string().describe("Local executable path"), + path: localPathStringSchema.describe("Local executable path"), pattern: bytePatternFields.pattern, }), options: z.object({ @@ -103,7 +104,9 @@ export const registerCoreNativeCommands = ( cli.command(CLI_COMMANDS.inspectNativeLoadImage, { description: "Inspect native load observations and supported snapshot verification", - args: z.object({ path: z.string().describe("Local executable path") }), + args: z.object({ + path: localPathStringSchema.describe("Local executable path"), + }), options: z.object({ ...analysisEvidenceDeliveryOptions, ...nativeOpenOptionFields, @@ -133,7 +136,7 @@ export const registerCoreNativeCommands = ( cli.command(CLI_COMMANDS.readBytes, { description: "Read exact provider memory bytes at one analysis address", args: z.object({ - path: z.string().describe("Local executable path"), + path: localPathStringSchema.describe("Local executable path"), address: z.string().describe("Exact provider memory address"), }), options: z.object({ @@ -158,7 +161,7 @@ export const registerCoreNativeCommands = ( cli.command(CLI_COMMANDS.addressToFileOffset, { description: "Resolve an analysis address to its original file byte offset", args: z.object({ - path: z.string().describe("Local executable path"), + path: localPathStringSchema.describe("Local executable path"), address: z.string().describe("Exact provider memory address"), }), options: z.object({ @@ -177,7 +180,9 @@ export const registerCoreNativeCommands = ( cli.command(CLI_COMMANDS.traceNativeValues, { description: "Trace bounded native def-use and call dependencies", args: z.object({ - path: z.string().describe("Local native executable or app path"), + path: localPathStringSchema.describe( + "Local native executable or app path", + ), procedure: nativeValueTraceInputSchema.shape.procedure.describe( "Explicit native procedure symbol or address", ), @@ -287,7 +292,9 @@ export const registerCoreNativeCommands = ( ? "Inspect one native instruction with decoded operand facts" : "Resolve one static native call site", args: z.object({ - path: z.string().describe("Local native executable or app path"), + path: localPathStringSchema.describe( + "Local native executable or app path", + ), address: z.string().describe("Exact native address"), }), options: z.object({ @@ -317,7 +324,9 @@ const registerNativeDispatchMetadataCommand = ( cli.command(CLI_COMMANDS.inspectNativeDispatchMetadata, { description: "Inspect typed Objective-C and Swift dispatch metadata", args: z.object({ - path: z.string().describe("Target path used to bind the result evidence"), + path: localPathStringSchema.describe( + "Target path used to bind the result evidence", + ), }), options: z.object({ maxRecords: nativeDispatchMetadataInputSchema.shape.max_records.describe( @@ -355,7 +364,9 @@ const registerNativeUiActionCommand = ( description: "Trace a compiled UI action selector to its native handler and direct callees", args: z.object({ - path: z.string().describe("Target path used to bind the result evidence"), + path: localPathStringSchema.describe( + "Target path used to bind the result evidence", + ), action: z .string() .min(1) @@ -412,7 +423,7 @@ const registerNativeApiCommand = ( cli.command(CLI_COMMANDS.inspectNativeApi, { description: `Reconstruct one native function API boundary with evidence. ${addressOrProcedureGuidance} ${equalSelectorGuidance}`, args: z.object({ - path: z.string().describe("App or program path"), + path: localPathStringSchema.describe("App or program path"), address: z .string() .optional() @@ -455,7 +466,7 @@ const registerAnnotationCommand = ( cli.command(CLI_COMMANDS.annotateNativeFunction, { description: "Edit function annotations and return refreshed analysis", args: z.object({ - path: z.string().describe("Local executable path"), + path: localPathStringSchema.describe("Local executable path"), procedure: z .string() .describe("Exact function entry address or unique name"), diff --git a/src/cli/managedCommands.ts b/src/cli/managedCommands.ts index 79f34a7c3a..daa09c2b27 100644 --- a/src/cli/managedCommands.ts +++ b/src/cli/managedCommands.ts @@ -41,7 +41,9 @@ const registerInspectManagedArtifact = ( cli.command(CLI_COMMANDS.inspectManagedArtifact, { description: "Inspect PE/CLI identity without loading target code", args: z.object({ - path: z.string().describe("Managed PE executable or assembly path"), + path: localPathStringSchema.describe( + "Managed PE executable or assembly path", + ), }), run: ({ args }) => logCliCommand(logger, "inspect-managed-artifact", () => @@ -64,7 +66,9 @@ const registerInspectManagedMembers = ( description: "Inspect PE/CLI metadata members, signatures, CIL anchors, and ldstr string literals without loading target code", args: z.object({ - path: z.string().describe("Managed PE executable or assembly path"), + path: localPathStringSchema.describe( + "Managed PE executable or assembly path", + ), }), options: z.object({ type: managedMembersInputSchema.shape.type_names.describe( @@ -111,7 +115,9 @@ const registerInspectManagedNativeBoundaries = ( description: "Inspect PE/CLI PInvoke and native implementation boundary declarations without loading target code", args: z.object({ - path: z.string().describe("Managed PE executable or assembly path"), + path: localPathStringSchema.describe( + "Managed PE executable or assembly path", + ), }), run: ({ args }) => logCliCommand(logger, "inspect-managed-native-boundaries", () => @@ -126,8 +132,12 @@ const registerInspectManagedNativeBoundaries = ( }; const compareManagedMemberArgs = z.object({ - leftPath: z.string().describe("Baseline managed PE executable or assembly"), - rightPath: z.string().describe("Candidate managed PE executable or assembly"), + leftPath: localPathStringSchema.describe( + "Baseline managed PE executable or assembly", + ), + rightPath: localPathStringSchema.describe( + "Candidate managed PE executable or assembly", + ), }); const registerCompareManagedMembers = ( diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index 382c48bba3..f565dbf9fc 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -24,6 +24,7 @@ import { PRODUCT_IDENTITY } from "../identity.js"; import { isCliOperationFailure, logCliCommand } from "../cliLogging.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { swiftSymbolsSchema } from "../contracts/native/nativeToolContracts.js"; +import { localPathStringSchema } from "../domain/localPath.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { registerNativeCallCommands } from "./nativeCallCommands.js"; @@ -96,7 +97,7 @@ const registerNativeUiCommands = ( runProviderAnalysis: DirectAnalysis["runProviderAnalysis"], ): void => { const pathArgument = z.object({ - path: z.string().describe("Local native executable or app path"), + path: localPathStringSchema.describe("Local native executable or app path"), }); cli.command(CLI_COMMANDS.observeNativeUi, { description: @@ -198,7 +199,9 @@ const registerNativeCommands = ( ] as const) { cli.command(command, { description: `Run ${tool} without launching Hopper`, - args: z.object({ path: z.string().describe("Mach-O or app path") }), + args: z.object({ + path: localPathStringSchema.describe("Mach-O or app path"), + }), options: z.object({ nativeCommandTimeoutMs: nativeCommandTimeoutField, }), @@ -217,7 +220,9 @@ const registerNativeCommands = ( } cli.command(CLI_COMMANDS.inspectPlist, { description: "Parse app plist metadata without launching Hopper", - args: z.object({ path: z.string().describe("App or Mach-O path") }), + args: z.object({ + path: localPathStringSchema.describe("App or Mach-O path"), + }), options: z.object({ relativePath: z .string() @@ -247,7 +252,9 @@ const registerNativeCommands = ( cli.command(CLI_COMMANDS.demangleSwift, { description: "Demangle Swift symbols without Hopper", args: z.object({ - path: z.string().describe("Artifact path used for evidence identity"), + path: localPathStringSchema.describe( + "Artifact path used for evidence identity", + ), symbols: swiftSymbolsSchema.describe("Swift mangled symbols to demangle"), }), options: z.object({ diff --git a/tests/boundary/cli/emptyLocalTargetAdmission.test.ts b/tests/boundary/cli/emptyLocalTargetAdmission.test.ts new file mode 100644 index 0000000000..4e28416a72 --- /dev/null +++ b/tests/boundary/cli/emptyLocalTargetAdmission.test.ts @@ -0,0 +1,118 @@ +import { Cli } from "incur"; +import { expect, it, vi } from "vitest"; +import { registerCoreBinaryCommands } from "../../../src/cli/coreBinaryCommands.js"; +import { registerCoreNativeCommands } from "../../../src/cli/coreNativeCommands.js"; +import { registerManagedCommands } from "../../../src/cli/managedCommands.js"; +import { registerUtilityCommands } from "../../../src/cli/utilityCommands.js"; +import { silentLogger } from "../../../src/logger.js"; + +const registry = () => { + const failure = { + ok: false as const, + error: { code: "invalid_request", message: "Admitted target control" }, + }; + const run = () => + vi.fn(async (_path: string, ..._rest: unknown[]) => failure); + const runDirectAnalysis = run(); + const runProviderAnalysis = run(); + const cli = Cli.create("empty-target-admission", { sync: false }); + registerCoreBinaryCommands(cli, silentLogger, runDirectAnalysis, {}); + registerCoreNativeCommands(cli, silentLogger, runDirectAnalysis); + registerManagedCommands(cli, silentLogger, runProviderAnalysis); + registerUtilityCommands( + cli, + silentLogger, + {}, + { + runProviderAnalysis, + runSessionStatus: async () => ({ ok: false, error: null }), + }, + ); + const serve = async (argv: readonly string[]): Promise => { + let stdout = ""; + await cli.serve([...argv, "--json"], { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }); + return JSON.parse(stdout); + }; + return { serve, runs: [runDirectAnalysis, runProviderAnalysis] }; +}; + +// Each command with its other required arguments; the target comes first. +const commands: readonly (readonly string[])[] = [ + ["inspect"], + ["decompile", "main"], + ["xrefs", "0x1000"], + ["trace", "feature"], + ["function", "0x1000"], + ["instructions", "0x1000"], + ["search", "name"], + ["search-bytes", "00"], + ["inspect-native-load-image"], + ["read-bytes", "0x1000"], + ["address-to-file-offset", "0x1000"], + ["trace-native-values", "main"], + ["inspect-native-instruction", "0x1000"], + ["resolve-native-call-targets", "0x1000"], + ["inspect-native-dispatch-metadata"], + ["trace-native-ui-action", "openDocument:"], + ["inspect-native-api", "0x1000"], + ["annotate-native-function", "main", "--name", "entry"], + ["inspect-managed-artifact"], + ["inspect-managed-members"], + ["inspect-managed-native-boundaries"], + ["observe-native-ui", "--pid", "1", "--window-id", "1"], + [ + "capture-native-ui-scenario", + "--pid", + "1", + "--window-id", + "1", + "--steps", + '[{"kind":"wait","milliseconds":0}]', + ], + ["inspect-macho"], + ["inspect-signature"], + ["list-architectures"], + ["inspect-plist"], + ["demangle-swift", "$s4main"], +]; + +it.each(commands)( + "rejects an empty %s target before resolution and admits explicit .", + async (command, ...rest) => { + const { serve, runs } = registry(); + // docs/cli.md: an empty string never selects the working directory. + expect(await serve([command, "", ...rest])).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [ + expect.objectContaining({ path: "path", code: "too_small" }), + ], + }); + for (const run of runs) expect(run).not.toHaveBeenCalled(); + await serve([command, ".", ...rest]); + expect(runs.flatMap((run) => run.mock.calls.map(([path]) => path))).toEqual( + ["."], + ); + }, +); + +it.each([ + { argv: ["", "."], field: "leftPath" }, + { argv: [".", ""], field: "rightPath" }, +])( + "rejects an empty compare-managed-members $field before reading either input", + async ({ argv, field }) => { + const { serve } = registry(); + expect(await serve(["compare-managed-members", ...argv])).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [ + expect.objectContaining({ path: field, code: "too_small" }), + ], + }); + }, +);