diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index fe752a9922..0e9f66547f 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -1,6 +1,13 @@ import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { nativeDispatchMetadataInputSchema } from "../domain/native/objcSwiftMetadata.js"; import { z } from "incur"; +import { + bytePatternFields, + validateBytePattern, + MAX_BYTE_SEARCH_MATCHES, +} from "../domain/native/bytePattern.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; + import { addressOrProcedureGuidance, equalSelectorGuidance, @@ -25,6 +32,10 @@ import { } from "./analysisEvidenceDelivery.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +const bytePatternInputSchema = z + .object(bytePatternFields) + .superRefine(validateBytePattern); + /** Register native load-image, memory, and annotation CLI commands. */ export const registerCoreNativeCommands = ( cli: CliInstance, @@ -36,10 +47,10 @@ export const registerCoreNativeCommands = ( description: "Find masked byte patterns in initialized provider memory", args: z.object({ path: z.string().describe("Local executable path"), - pattern: z.string().describe("Hexadecimal byte pairs with ?? wildcards"), + pattern: bytePatternFields.pattern, }), options: z.object({ - mask: z.string().optional().describe("Equal-length hexadecimal bit mask"), + mask: bytePatternFields.mask, start: z.string().optional().describe("Inclusive start address"), end: z.string().optional().describe("Inclusive end address"), cursor: z @@ -50,27 +61,43 @@ export const registerCoreNativeCommands = ( .number() .int() .min(1) - .max(1000) + .max(MAX_BYTE_SEARCH_MATCHES) .default(100) .describe("Maximum matches on this page"), ...nativeOpenOptionFields, }), run: ({ args, options }) => - logCliCommand(logger, CLI_COMMANDS.searchBytes, () => - runDirectAnalysis( + logCliCommand(logger, CLI_COMMANDS.searchBytes, async () => { + const selection = { + pattern: args.pattern, + ...(options.mask === undefined ? {} : { mask: options.mask }), + }; + const parsed = bytePatternInputSchema.safeParse(selection); + if (!parsed.success) + return projectAnalysisError( + analysisInputErrorFromIssues( + "search_bytes", + parsed.error.issues, + selection, + { cause: parsed.error }, + ), + ); + return runDirectAnalysis( args.path, "search_bytes", { - pattern: args.pattern, + pattern: parsed.data.pattern, + ...(parsed.data.mask === undefined + ? {} + : { mask: parsed.data.mask }), limit: options.limit, - ...(options.mask === undefined ? {} : { mask: options.mask }), ...(options.start === undefined ? {} : { start: options.start }), ...(options.end === undefined ? {} : { end: options.end }), ...(options.cursor === undefined ? {} : { cursor: options.cursor }), }, directAnalysisOptionsFromCli(logger, options), - ).then(projectCliAnalysisResult), - ), + ).then(projectCliAnalysisResult); + }), }); cli.command(CLI_COMMANDS.inspectNativeLoadImage, { description: diff --git a/tests/acceptance/searchBytesAdmissionCli.test.ts b/tests/acceptance/searchBytesAdmissionCli.test.ts new file mode 100644 index 0000000000..cd2dbecb37 --- /dev/null +++ b/tests/acceptance/searchBytesAdmissionCli.test.ts @@ -0,0 +1,54 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { cliTest } from "../support/cli/cliFixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +cliTest.for([ + { pattern: "??", mask: undefined, field: "pattern" }, + { pattern: "ZZ", mask: undefined, field: "pattern" }, + { pattern: "", mask: undefined, field: "pattern" }, + { pattern: "AB", mask: "FFFF", field: "mask" }, +])( + "reports query correction before target refusal ($pattern/$mask)", + async ({ pattern, mask, field }, { cli }) => { + const root = await createTestTempDirectory("rea-byte-admission-"); + const text = join(root, "text.bin"); + await writeFile(text, "hello world"); + for (const path of [text, join(root, "missing")]) { + const result = await cli.run({ + arguments: [ + "search-bytes", + path, + pattern, + ...(mask === undefined ? [] : ["--mask", mask]), + "--json", + ], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + details: { + issues: [ + expect.objectContaining({ path: [field], reason: "invalid_value" }), + ], + }, + }); + } + }, +); + +cliTest("keeps target admission for a valid byte query", async ({ cli }) => { + const root = await createTestTempDirectory("rea-byte-valid-"); + const path = join(root, "text.bin"); + await writeFile(path, "hello world"); + const result = await cli.run({ + arguments: ["search-bytes", path, "AB", "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "target_unavailable", + category: "unsupported_target", + }); +}); diff --git a/tests/boundary/cli/searchBytesAdmission.test.ts b/tests/boundary/cli/searchBytesAdmission.test.ts new file mode 100644 index 0000000000..97ad8fc1ab --- /dev/null +++ b/tests/boundary/cli/searchBytesAdmission.test.ts @@ -0,0 +1,106 @@ +import { Cli } from "incur"; +import pino from "pino"; +import { expect, it, vi } from "vitest"; +import { registerCoreNativeCommands } from "../../../src/cli/coreNativeCommands.js"; + +const registry = () => { + const run = vi.fn(async () => ({ + ok: false as const, + error: { code: "invalid_request", message: "Admitted query control" }, + })); + const cli = Cli.create("byte-admission", { sync: false }); + registerCoreNativeCommands(cli, pino({ enabled: false }), run); + return { cli, run }; +}; + +it.each([ + { pattern: "??", mask: undefined, field: "pattern" }, + { pattern: "ZZ", mask: undefined, field: "pattern" }, + { pattern: "", mask: undefined, field: "pattern" }, + { pattern: "AB", mask: "FFFF", field: "mask" }, + { pattern: "AB", mask: "00", field: "pattern" }, +])( + "rejects $pattern/$mask before any target access", + async ({ pattern, mask, field }) => { + const { cli, run } = registry(); + let stdout = ""; + await cli.serve( + [ + "search-bytes", + "/missing-target", + pattern, + ...(mask === undefined ? [] : ["--mask", mask]), + "--json", + ], + { + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }, + ); + expect(run).not.toHaveBeenCalled(); + expect(JSON.parse(stdout)).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + details: { + operation: "search_bytes", + issues: [ + expect.objectContaining({ path: [field], reason: "invalid_value" }), + ], + }, + }); + }, +); + +it("forwards admitted original strings and caller-selected search/provider controls", async () => { + const { cli, run } = registry(); + await cli.serve( + [ + "search-bytes", + "/selected", + "ab ?? CD", + "--mask", + "FF 00 ff", + "--start", + "0x10", + "--end", + "0x20", + "--cursor", + "next", + "--limit", + "7", + "--provider", + "ghidra", + "--json", + ], + { stdout: () => undefined, exit: () => undefined }, + ); + expect(run).toHaveBeenCalledExactlyOnceWith( + "/selected", + "search_bytes", + { + pattern: "ab ?? CD", + mask: "FF 00 ff", + start: "0x10", + end: "0x20", + cursor: "next", + limit: 7, + }, + expect.objectContaining({ providerId: "ghidra" }), + ); +}); + +it("keeps an omitted mask omitted", async () => { + const { cli, run } = registry(); + await cli.serve(["search-bytes", "/selected", "AB", "--json"], { + stdout: () => undefined, + exit: () => undefined, + }); + expect(run).toHaveBeenCalledExactlyOnceWith( + "/selected", + "search_bytes", + { pattern: "AB", limit: 100 }, + expect.any(Object), + ); +});