From 5c2917266d738c324a3a49e18e91fb4aacd0b6e8 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:43:30 +0800 Subject: [PATCH] fix(inputs): validate command admission and preserve valid MCP requests (#9901) * fix(cli): validate named inputs before target admission * fix(mcp): preserve valid inputs when advertised schema references widen * test(inputs): reach session behavior with admitted inputs One-shot admission now rejects incomplete requests before a session exists. Lifecycle tests supply the required fields, and unknown arguments are rejected before provider issues are retained. * fix(cli): preserve Electron fuse path spelling Anchor relative fuse paths without stripping dot segments, and let an empty path reach the operation so it still reports invalid_request. * fix(cli): reject outline-depth unless detail is summary The advertised schema already required detail summary whenever outline-depth was present. The parser still applied the full default and accepted that combination, so its output failed the same schema. Parsing now rejects it before the command runs. --------- Co-authored-by: Cursor Agent --- docs/getting-started/cli.md | 7 + src/application/BinaryTargetResolver.ts | 21 +- src/application/DirectAnalysis.ts | 111 +++--- .../il2cpp/Il2cppMetadataService.test.ts | 26 ++ .../il2cpp/Il2cppMetadataService.ts | 26 -- src/application/oneShotOperationInput.ts | 111 ++++++ src/cli/binaryDiagnosticsCommands.ts | 3 +- src/cli/captureCommands.ts | 14 +- src/cli/coreNativeCommands.ts | 14 +- src/cli/desktopCaptureCommands.ts | 12 +- src/cli/electronCommands.ts | 14 +- src/cli/fridaCommands.ts | 5 +- src/cli/il2cppCommands.ts | 73 ++-- src/cli/nativeFunctionMatchingCommands.ts | 7 +- src/cli/omittableDefaults.ts | 4 +- src/cli/pcapCommands.ts | 8 +- src/cli/reverseEngineeringCommands.ts | 19 +- src/cli/signalCommands.ts | 14 +- src/cli/utilityCommands.ts | 80 +++-- .../inputObservationPresentation.test.ts | 318 ++++++++++++++++++ src/contracts/inputSchemaPresentation.ts | 262 ++++++++++++--- src/domain/captures/packetCapture.ts | 7 +- src/domain/il2cpp/il2cppMetadata.ts | 78 +++-- src/domain/localPath.test.ts | 13 + src/domain/localPath.ts | 9 +- src/domain/native/nativeProcedureInput.ts | 2 +- src/domain/process/processArgument.ts | 6 + src/domain/signals/signalRecording.ts | 7 +- .../boundary/cli/analysisSelectorCli.test.ts | 31 ++ .../boundary/cli/cliDomainFieldSchema.test.ts | 114 +++++++ .../cli/cliInputSchemaRequired.test.ts | 25 +- .../boundary/filesystem/binaryTarget.test.ts | 17 + .../composition/directAnalysisCleanup.test.ts | 118 ++++--- .../directAnalysisFactories.test.ts | 92 +++-- tests/composition/directAnalysisXrefs.test.ts | 32 +- 35 files changed, 1340 insertions(+), 360 deletions(-) create mode 100644 src/application/oneShotOperationInput.ts create mode 100644 src/domain/process/processArgument.ts diff --git a/docs/getting-started/cli.md b/docs/getting-started/cli.md index 39151094a6..8e8c809798 100644 --- a/docs/getting-started/cli.md +++ b/docs/getting-started/cli.md @@ -651,6 +651,13 @@ be verified. See [MCP contracts](../reference/mcp-contracts.md) for session life [Ghidra first-query deadlines](../reference/mcp-contracts.md#ghidra-first-query-deadlines-and-recovery) for import, client timeouts and recovery. +One-shot binary analysis validates the selected operation's input before creating +a provider session, opening the target, or replaying a snapshot. Invalid selectors +and option combinations report their input errors before target or provider +availability errors. An already-cancelled request keeps its cancellation reason. +Caller-selected paths and request parameters remain separate from parsed execution +arguments in Evidence. + A one-shot command attempts provider-session and runtime cleanup before reporting a failure. If cleanup remains incomplete, inspect the reported resources and follow the error's remediation. [Same-session cleanup retry](../reference/mcp-contracts.md#retained-cleanup-retry), diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 819d7acf20..7302bb19af 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -143,6 +143,7 @@ import { type MachoLoadCommandTableHeader, } from "../domain/apple/machoLoadCommands.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { localPathStringSchema } from "../domain/localPath.js"; import type { JsonValue } from "../domain/jsonValue.js"; const XML_PLIST_PROBE_CHUNK_BYTES = 64 * 1024; @@ -173,9 +174,25 @@ export const parseBinaryTarget = async ( ): Promise< Result > => { - const cwd = options.cwd ?? process.cwd(); const { signal } = options; - const candidate = absoluteCallerPath(input, { cwd }); + const admitted = localPathStringSchema.safeParse(input); + if (!admitted.success) { + if (signal?.aborted === true) + return err( + new AnalysisCancelledError("resolve_binary_target", { + cause: signal.reason, + }), + ); + return err( + new BinaryTargetError( + input, + admitted.error.issues[0]?.message ?? "Invalid local path", + { cause: admitted.error }, + ), + ); + } + const cwd = options.cwd ?? process.cwd(); + const candidate = absoluteCallerPath(admitted.data, { cwd }); // Failures before canonicalization concern the selected path itself; later // failures concern the canonical path opened with symbolic links rejected. let stage: TargetFilesystemStage = "resolve"; diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index a0b132b894..589d76b9f3 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -1,3 +1,7 @@ +import { + admitOneShotOperation, + type DirectAnalysisTool, +} from "./oneShotOperationInput.js"; import { admitCompletedAnalysis } from "./investigation/admitCompletedAnalysis.js"; import type { EspFlashSelection } from "../domain/firmware/espFlashLocation.js"; import type { ExecutableFormatHint } from "../domain/dosCom.js"; @@ -96,30 +100,6 @@ import { import { completeProviderEvidence } from "./ProviderOperationExecution.js"; import { BUNDLE_RESOURCE_OPERATIONS } from "./binary/BundleResourceSession.js"; -type DirectAnalysisTool = - | "annotate_native_function" - | "inspect_native_load_image" - | "read_bytes" - | "search_bytes" - | "address_to_file_offset" - | "binary_overview" - | "procedure_pseudo_code" - | "procedure_references" - | "read_function_instructions" - | "inspect_native_instruction" - | "inspect_native_data_type" - | "resolve_native_call_targets" - | "analyze_function" - | "inspect_native_api" - | "inspect_native_dispatch_metadata" - | "search_strings" - | "search_procedures" - | "xrefs" - | "trace_feature" - | "trace_native_ui_action" - | "trace_native_values" - | "decompile_all"; - // These one-shot CLI questions require native utilities, not a deep engine. // Runtime observation retains the ordinary session and authority checks. const NATIVE_INSPECTION_OPERATIONS: ReadonlySet = new Set([ @@ -158,20 +138,30 @@ export const runDirectAnalysis = async ( readonly includeEvidenceSources?: boolean; } = {}, ): Promise> => - withProcessCancellation(options.signal, (signal) => - runAnalysis(dependencies, path, tool, arguments_, { - logger: options.logger ?? silentLogger, - snapshotPath: options.snapshotPath, - signal, - ...(options.includeEvidenceSources === true - ? { includeEvidenceSources: true } - : {}), - ...targetSelection(options), - ...(options.providerId === undefined - ? {} - : { providerId: options.providerId }), - }), - ); + withProcessCancellation(options.signal, (signal) => { + const admitted = admitOneShotOperation(tool, arguments_, signal); + if (!admitted.ok) + return Promise.resolve(err(cliError(admitted.error, path))); + return runAnalysis( + dependencies, + path, + admitted.value.name, + admitted.value.input, + { + evidenceParameters: arguments_, + logger: options.logger ?? silentLogger, + snapshotPath: options.snapshotPath, + signal, + ...(options.includeEvidenceSources === true + ? { includeEvidenceSources: true } + : {}), + ...targetSelection(options), + ...(options.providerId === undefined + ? {} + : { providerId: options.providerId }), + }, + ); + }); /** How the caller asks an artifact target to be read. */ export interface ArtifactTargetSelection { @@ -268,8 +258,7 @@ export const runProviderAnalysis = async ( ), ); return withProcessCancellation(signal, async (operationSignal) => { - // The selected CLI path is a request fact. Keep it out of provider inputs - // and target identity, which may use the resolved executable instead. + // Preserve the selected path and original arguments as request facts. const evidenceParameters = { ...arguments_, input_path: path }; if (isManagedToolName(tool)) { const original = await runManagedProviderAnalysis( @@ -309,18 +298,26 @@ export const runProviderAnalysis = async ( ); return result.ok ? ok(result.value) : err(cliError(result.error, path)); } - return runAnalysis(dependencies, path, tool, arguments_, { - logger, - snapshotPath: undefined, - signal: operationSignal, - evidenceParameters, - ...targetSelection(selection ?? {}), - ...(ARTIFACT_ANALYSIS_OPERATION_SET.has(tool) - ? { createSession: dependencies.createArtifactBinarySession } - : NATIVE_INSPECTION_OPERATIONS.has(tool) - ? { createSession: dependencies.createNativeBinarySession } - : {}), - }); + const admitted = admitOneShotOperation(tool, arguments_, operationSignal); + if (!admitted.ok) return err(cliError(admitted.error, path)); + return runAnalysis( + dependencies, + path, + admitted.value.name, + admitted.value.input, + { + logger, + snapshotPath: undefined, + signal: operationSignal, + evidenceParameters, + ...targetSelection(selection ?? {}), + ...(ARTIFACT_ANALYSIS_OPERATION_SET.has(tool) + ? { createSession: dependencies.createArtifactBinarySession } + : NATIVE_INSPECTION_OPERATIONS.has(tool) + ? { createSession: dependencies.createNativeBinarySession } + : {}), + }, + ); }); }; @@ -334,6 +331,8 @@ export const runManagedProviderExecution = async ( recoveryParameters?: Readonly>, ): Promise> => withProcessCancellation(signal, async (operationSignal) => { + const admitted = admitOneShotOperation(tool, parameters, operationSignal); + if (!admitted.ok) return admitted; const session = await dependencies.createManagedBinarySession(); return withSessionCleanup( session, @@ -341,10 +340,12 @@ export const runManagedProviderExecution = async ( const opened = await session.open(path, { signal: operationSignal }); if (!opened.ok) return opened; return recoveryParameters === undefined - ? await session.execute(tool, parameters, { signal: operationSignal }) + ? await session.execute(admitted.value.name, admitted.value.input, { + signal: operationSignal, + }) : await session.executeForDirectObservation( - tool, - parameters, + admitted.value.name, + admitted.value.input, recoveryParameters, { signal: operationSignal }, ); diff --git a/src/application/il2cpp/Il2cppMetadataService.test.ts b/src/application/il2cpp/Il2cppMetadataService.test.ts index 761902a572..83dcffcd86 100644 --- a/src/application/il2cpp/Il2cppMetadataService.test.ts +++ b/src/application/il2cpp/Il2cppMetadataService.test.ts @@ -13,6 +13,9 @@ import { err, ok } from "../../domain/result.js"; import { EvidenceFileError } from "../../domain/evidenceErrors.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import type { Il2cppSeedWriterPort } from "./Il2cppSeedWriterPort.js"; +import { toolContract } from "../../contracts/toolContracts.js"; +import { z } from "zod"; +import { Ajv2020 } from "ajv/dist/2020.js"; const identity = { id: "fixture-il2cpp", @@ -21,6 +24,29 @@ const identity = { }; const report = createEmptyIl2cppMetadataFixture(); +it.each([{ binary_architecture: "arm64" }, { generic_instances: true }])( + "binds binary-dependent selectors in the named input contract: %j", + (selection) => { + const schema = toolContract("inspect_il2cpp_metadata").inputSchema; + const validate = new Ajv2020({ strict: false }).compile( + z.toJSONSchema(schema, { io: "input" }), + ); + const missingBinary = { path: report.artifact.path, ...selection }; + const selectedBinary = { + ...missingBinary, + binary_path: "/artifacts/GameAssembly.dll", + }; + expect(schema.safeParse(missingBinary).success).toBe(false); + expect(validate(missingBinary)).toBe(false); + expect(schema.safeParse(selectedBinary).success).toBe(true); + expect(validate(selectedBinary)).toBe(true); + expect( + schema.safeParse({ path: report.artifact.path, generic_instances: false }) + .success, + ).toBe(true); + }, +); + const serviceReturning = (value: Il2cppMetadata, calls = { count: 0 }) => new Il2cppMetadataService({ close: async () => {}, diff --git a/src/application/il2cpp/Il2cppMetadataService.ts b/src/application/il2cpp/Il2cppMetadataService.ts index 0c4c78136b..c77bb2410c 100644 --- a/src/application/il2cpp/Il2cppMetadataService.ts +++ b/src/application/il2cpp/Il2cppMetadataService.ts @@ -106,32 +106,6 @@ export class Il2cppMetadataService { options?.signal, ); if (!input.ok) return input; - if ( - input.value.binary_architecture !== undefined && - input.value.binary_path === undefined - ) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { - path: ["binary_architecture"], - reason: "invalid_value", - message: "binary_architecture selects a slice of binary_path.", - }, - ]), - ); - if ( - input.value.generic_instances === true && - input.value.binary_path === undefined - ) - return err( - new AnalysisInputError(OPERATION, undefined, [ - { - path: ["generic_instances"], - reason: "invalid_value", - message: "generic_instances links generic methods in binary_path.", - }, - ]), - ); if (output !== undefined) { if (input.value.binary_path === undefined) return err( diff --git a/src/application/oneShotOperationInput.ts b/src/application/oneShotOperationInput.ts new file mode 100644 index 0000000000..e8a6e20d18 --- /dev/null +++ b/src/application/oneShotOperationInput.ts @@ -0,0 +1,111 @@ +import type { z } from "zod"; +import { toolContract } from "../contracts/toolContracts.js"; +import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js"; +import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js"; +import { + ARTIFACT_TOOL_CONTRACTS, + artifactInventoryInputSchema, + type ArtifactAnalysisOperation, +} from "../contracts/artifactToolContracts.js"; +import { + MANAGED_TOOL_CONTRACTS, + managedArtifactInputSchema, + managedMembersInputSchema, + type ManagedToolName, +} from "../contracts/managed/managedToolContracts.js"; +import { enhancedInputSchemas } from "../contracts/enhancedInputs.js"; +import type { NativeToolName } from "../contracts/native/nativeToolContracts.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { parseAnalysisInput } from "./AnalysisInput.js"; +import { ok } from "../domain/result.js"; + +/** Operations the direct workflow can perform without a persistent session. */ +export type DirectAnalysisTool = + | "annotate_native_function" + | "inspect_native_load_image" + | "read_bytes" + | "search_bytes" + | "address_to_file_offset" + | "binary_overview" + | "procedure_pseudo_code" + | "procedure_references" + | "read_function_instructions" + | "inspect_native_instruction" + | "inspect_native_data_type" + | "resolve_native_call_targets" + | "analyze_function" + | "inspect_native_api" + | "inspect_native_dispatch_metadata" + | "search_strings" + | "search_procedures" + | "xrefs" + | "trace_feature" + | "trace_native_ui_action" + | "trace_native_values" + | "decompile_all"; + +type OneShotOperation = + | DirectAnalysisTool + | NativeToolName + | ArtifactAnalysisOperation + | ManagedToolName; +type Schema = z.ZodType>>; + +type ContractSchemas< + Contracts extends readonly { + readonly name: string; + readonly inputSchema: Schema; + }[], +> = { + [Contract in Contracts[number] as Contract["name"]]: Contract["inputSchema"]; +}; + +const schemasByName = < + const Contracts extends readonly { + readonly name: string; + readonly inputSchema: Schema; + }[], +>( + contracts: Contracts, +): ContractSchemas => + Object.fromEntries( + contracts.map(({ name, inputSchema }) => [name, inputSchema]), + ) as ContractSchemas; + +const schemas = { + ...schemasByName(OFFICIAL_TOOL_CONTRACTS), + ...schemasByName(NATIVE_TOOL_CONTRACTS), + ...schemasByName(ARTIFACT_TOOL_CONTRACTS), + ...schemasByName(MANAGED_TOOL_CONTRACTS), + ...enhancedInputSchemas, + inspect_native_load_image: toolContract( + "inspect_native_load_image", + ).inputSchema.omit({ detail: true }), + inspect_managed_artifact: managedArtifactInputSchema.omit({ detail: true }), + inspect_managed_members: managedMembersInputSchema.omit({ detail: true }), + inventory_artifact: artifactInventoryInputSchema, +} satisfies Record; + +/** Parse the named operation before target selection or snapshot replay. */ +export const admitOneShotOperation = ( + name: Name, + input: Readonly>, + signal?: AbortSignal, +) => { + type SelectedInput = Partial> & + Readonly>; + const parsed = parseAnalysisInput( + name, + input, + schemas[name] as Schema, + signal, + ); + if (!parsed.ok) return parsed; + // Admission owns the selected request: preserve top-level omissions for + // Evidence and snapshot identity while carrying each selected parsed value. + // Nested defaults remain in the owning field's parsed value. + const selected = Object.fromEntries( + Object.entries(parsed.value).filter(([key]) => Object.hasOwn(input, key)), + ) as SelectedInput; + return ok({ name, input: selected }); +}; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 58c644c257..18657da2a3 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -215,8 +215,7 @@ export const registerBinaryDiagnosticsCommands = ( maxIntegerTextBytes: cliDecimalField( inspectAppleIpsInputSchema.shape.max_integer_text_bytes, ), - joinPath: z - .string() + joinPath: localPathStringSchema .optional() .describe( "Optional local Mach-O executable or dSYM whose slice LC_UUID matches report images", diff --git a/src/cli/captureCommands.ts b/src/cli/captureCommands.ts index 4a85077550..8ac552c04d 100644 --- a/src/cli/captureCommands.ts +++ b/src/cli/captureCommands.ts @@ -59,6 +59,12 @@ export const registerCaptureCommands = ( path: ["all"], message: "all and limit are mutually exclusive", }); + }) + .meta({ + not: { + required: ["all", "limit"], + properties: { all: { const: true } }, + }, }), run: ({ options: { all, producerLimits, ...options } }) => withCommandCancellation((signal) => @@ -119,7 +125,13 @@ export const registerCaptureCommands = ( .refine( withinPacketCaptureSelectorBudget, packetCaptureSelectorBudgetIssue, - ), + ) + .meta({ + not: { + required: ["all_matches", "match_limit"], + properties: { all_matches: { const: true } }, + }, + }), run: ({ args, options: { all_matches, producerLimits, ...options } }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectPacketCapture, async () => { diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index 57d5883a2f..445c4fef78 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -1,3 +1,5 @@ +import { nativeInstructionInputSchema } from "../domain/native/nativeInstruction.js"; +import { nativeFunctionAnnotationsInputSchema } from "../domain/native/nativeFunctionAnnotations.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { nativeDispatchMetadataInputSchema } from "../domain/native/objcSwiftMetadata.js"; import { nativeInvestigationTraceLimitsSchema } from "../domain/native/nativeInvestigationGraph.js"; @@ -395,7 +397,9 @@ export const registerCoreNativeCommands = ( path: localPathStringSchema.describe( "Local native executable or app path", ), - address: z.string().describe("Exact native address"), + address: nativeInstructionInputSchema.shape.address.describe( + "Exact native address", + ), }), options: z.object({ ...nativeOpenOptionFields, @@ -557,11 +561,9 @@ const registerAnnotationCommand = ( description: "Edit function annotations and return refreshed analysis", args: z.object({ path: localPathStringSchema.describe("Local executable path"), - procedure: z - .string() - .describe( - "Function name or address; Ghidra resolves interior addresses to the containing function and edits its entry", - ), + procedure: nativeFunctionAnnotationsInputSchema.shape.procedure.describe( + "Function name or address; Ghidra resolves interior addresses to the containing function and edits its entry", + ), }), options: z.object({ name: z.string().min(1).optional().describe("Analyst function name"), diff --git a/src/cli/desktopCaptureCommands.ts b/src/cli/desktopCaptureCommands.ts index 5ed15fed04..bd6408dbd6 100644 --- a/src/cli/desktopCaptureCommands.ts +++ b/src/cli/desktopCaptureCommands.ts @@ -263,14 +263,12 @@ const withheldExportOptions = { .describe( "Seed the withheld files after all, which needs a seed_sign_in consent in --consent", ), - consent: z - .string() + consent: localPathStringSchema .optional() .describe( "consent.json holding the person's consents, which the bundle ships", ), - withheldCopy: z - .string() + withheldCopy: localPathStringSchema .optional() .describe( "New or empty folder outside the bundle the withheld files are copied into, for whoever supplies them at run time", @@ -340,8 +338,7 @@ const registerDesktopExportCommand = ( .describe( "http or https URL the computer downloads the captured --package-file from when the bundle is put, for an application installed from a .deb file", ), - downloadInstall: z - .string() + downloadInstall: localPathStringSchema .optional() .describe( "JSON file naming the AppImage or tarball a build computer unpacked the application from: kind, desktop_entry, url, sha256, architecture, and a tarball's program and name", @@ -358,8 +355,7 @@ const registerDesktopExportCommand = ( .describe( "The seed was made for the task, as in a build computer, not taken from the user's own data; the README says so instead of keeping the bundle local", ), - screens: z - .string() + screens: localPathStringSchema .optional() .describe( "Folder holding checkpoint screenshots for screen-judge examples.", diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index 087ecfa023..aaa177fef1 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -1,9 +1,14 @@ import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { parseCliAnalysisEvidenceDelivery } from "./analysisEvidenceDelivery.js"; import { javaScriptRoutesDeliverySchema } from "../domain/analysisView/javascriptRoutesView.js"; + import { isAbsolute, resolve, sep } from "node:path"; import { Cli, z } from "incur"; +import { + localPathStringSchema, + nulFreeStringSchema, +} from "../domain/localPath.js"; import { logCliCommand } from "../cliLogging.js"; import { @@ -37,7 +42,6 @@ import { evidenceBundleOption, runEvidenceInputCommand, } from "./evidenceInputCommand.js"; -import { localPathStringSchema } from "../domain/localPath.js"; /** Register CLI equivalents of the Electron MCP tools. */ export const registerElectronCommands = ( @@ -65,11 +69,9 @@ const registerElectronFuseCommand = ( description: "Inspect packaged Electron fuse bytes without launching the application", args: z.object({ - path: z - .string() - .describe( - "Local Electron .app, Electron Framework binary, PE executable, or ELF executable", - ), + path: nulFreeStringSchema.describe( + "Local Electron .app, Electron Framework binary, PE executable, or ELF executable", + ), }), run: ({ args }) => withCommandCancellation((signal) => diff --git a/src/cli/fridaCommands.ts b/src/cli/fridaCommands.ts index f668c2554f..94a9c4f7f5 100644 --- a/src/cli/fridaCommands.ts +++ b/src/cli/fridaCommands.ts @@ -1,6 +1,7 @@ import { fridaCapturePolicyInputSchema } from "../domain/fridaCapturePolicy.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; +import { localPathStringSchema } from "../domain/localPath.js"; import { FridaInstrumentationService } from "../application/frida/FridaInstrumentationService.js"; import type { @@ -166,9 +167,7 @@ export const registerFridaCommands = ( .string() .optional() .describe(`Inline JavaScript to load. ${exactlyOneScriptSource}`), - scriptFile: z - .string() - .min(1) + scriptFile: localPathStringSchema .optional() .describe( `Path to JavaScript source file. ${exactlyOneScriptSource}`, diff --git a/src/cli/il2cppCommands.ts b/src/cli/il2cppCommands.ts index f1c414daeb..0a1a36ed51 100644 --- a/src/cli/il2cppCommands.ts +++ b/src/cli/il2cppCommands.ts @@ -27,36 +27,49 @@ export const registerIl2cppCommands = ( "Local global-metadata.dat (version 24, 27, 29, 31 or 39)", ), }), - options: z.object({ - ...analysisEvidenceDeliveryOptions, - binary: localPathStringSchema - .optional() - .describe( - "Matching native binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so) to link method addresses; read only", - ), - architecture: z - .enum(["arm64", "amd64"]) - .optional() - .describe("Slice of a universal Mach-O binary"), - genericInstances: z - .boolean() - .optional() - .describe( - "Also link generic method instances (Type$$Method) and include them in --ghidra-seeds; needs --binary", - ), - ghidraSeeds: localPathStringSchema - .optional() - .describe( - "Also write REA_GHIDRA_SEED_FILE function seeds named Type$$Method for every linked method; needs --binary", - ), - seedBase: z - .string() - .regex(/^0x[0-9a-fA-F]{1,16}$/u) - .optional() - .describe( - "Address Ghidra loads the binary at; seeds are seed-base + RVA (default: Ghidra's loader default, 0x100000 for an ELF linked at 0, else the link base)", - ), - }), + options: z + .object({ + ...analysisEvidenceDeliveryOptions, + binary: localPathStringSchema + .optional() + .describe( + "Matching native binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so) to link method addresses; read only", + ), + architecture: z + .enum(["arm64", "amd64"]) + .optional() + .describe("Slice of a universal Mach-O binary"), + genericInstances: z + .boolean() + .optional() + .describe( + "Also link generic method instances (Type$$Method) and include them in --ghidra-seeds; needs --binary", + ), + ghidraSeeds: localPathStringSchema + .optional() + .describe( + "Also write REA_GHIDRA_SEED_FILE function seeds named Type$$Method for every linked method; needs --binary", + ), + seedBase: z + .string() + .regex(/^0x[0-9a-fA-F]{1,16}$/u) + .optional() + .describe( + "Address Ghidra loads the binary at; seeds are seed-base + RVA (default: Ghidra's loader default, 0x100000 for an ELF linked at 0, else the link base)", + ), + }) + .meta({ + dependentRequired: { + architecture: ["binary"], + ghidraSeeds: ["binary"], + seedBase: ["ghidraSeeds"], + }, + if: { + properties: { genericInstances: { const: true } }, + required: ["genericInstances"], + }, + then: { required: ["binary"] }, + }), run: ({ args, options }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectIl2cppMetadata, async () => { diff --git a/src/cli/nativeFunctionMatchingCommands.ts b/src/cli/nativeFunctionMatchingCommands.ts index e26f9fd38b..1ee0d0ae76 100644 --- a/src/cli/nativeFunctionMatchingCommands.ts +++ b/src/cli/nativeFunctionMatchingCommands.ts @@ -18,6 +18,7 @@ import { import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; import type { CliInstance } from "./types.js"; import { parseNativeMatchingAllowance } from "../config/nativeMatchingAllowance.js"; +import { localPathStringSchema } from "../domain/localPath.js"; /** Resolve portable input bundles, then use the same read-only matching service as MCP. */ export const registerNativeFunctionMatchingCommands = ( @@ -37,14 +38,12 @@ export const registerNativeFunctionMatchingCommands = ( }), options: z.object({ source_bundle: z - .array(z.string()) + .array(localPathStringSchema) .default([]) .describe( "Canonical source Evidence bundle for retained references; repeat for each input", ), - evidence_output: z - .string() - .min(1) + evidence_output: localPathStringSchema .optional() .describe( "Save full matching Evidence and selected sources; required for default summary; never overwrites", diff --git a/src/cli/omittableDefaults.ts b/src/cli/omittableDefaults.ts index 6cf381dfe6..1f9f3038c8 100644 --- a/src/cli/omittableDefaults.ts +++ b/src/cli/omittableDefaults.ts @@ -33,11 +33,13 @@ const withOmittableDefaults = (schema: z.ZodObject): z.ZodObject => { fillsOmission(field), ); if (omittable.length === 0) return schema; - return schema.safeExtend( + const metadata = schema.meta(); + const result = schema.safeExtend( Object.fromEntries( omittable.map(([key, field]) => [key, optionalWithMetadata(field)]), ), ); + return metadata === undefined ? result : result.meta(metadata); }; /** Let the object parser decide omission; Incur advertises required outputs. */ diff --git a/src/cli/pcapCommands.ts b/src/cli/pcapCommands.ts index 49721123b9..aeaa89a48d 100644 --- a/src/cli/pcapCommands.ts +++ b/src/cli/pcapCommands.ts @@ -1,5 +1,6 @@ import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; +import { localPathStringSchema } from "../domain/localPath.js"; import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js"; import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js"; import { createPcapAnalysisProvider } from "../composition/pcap.js"; @@ -53,10 +54,9 @@ export const registerPcapCommands = ( description: "Inspect one pcap/pcapng capture: endpoints, DNS, TLS SNI, and protocol distribution", args: z.object({ - path: z - .string() - .min(1) - .describe("Local pcap or pcapng capture file to inspect"), + path: localPathStringSchema.describe( + "Local pcap or pcapng capture file to inspect", + ), }), run: ({ args }) => execute( diff --git a/src/cli/reverseEngineeringCommands.ts b/src/cli/reverseEngineeringCommands.ts index 496d83e5f9..186660ab45 100644 --- a/src/cli/reverseEngineeringCommands.ts +++ b/src/cli/reverseEngineeringCommands.ts @@ -1,4 +1,5 @@ import { z } from "incur"; +import { localPathStringSchema } from "../domain/localPath.js"; import type { Logger } from "pino"; import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js"; import { RIZIN_PROVIDER_IDENTITY } from "../rizin/RizinProviderIdentity.js"; @@ -151,10 +152,9 @@ export const registerReverseEngineeringCommands = ( description: "Inspect a local artifact using GNU-style objdump arguments and retain the selected command output as Evidence", args: z.object({ - path: z - .string() - .min(1) - .describe("Path to the local artifact to inspect."), + path: localPathStringSchema.describe( + "Path to the local artifact to inspect.", + ), operation: z .enum(OBJDUMP_ARTIFACT_OPERATIONS) .describe("GNU-style objdump operation to run."), @@ -274,10 +274,9 @@ export const registerReverseEngineeringCommands = ( cli.command(CLI_COMMANDS.executeRizinCommand, { description: "Execute one Rizin command against a local artifact", args: z.object({ - path: z - .string() - .min(1) - .describe("Path to the local artifact to analyze."), + path: localPathStringSchema.describe( + "Path to the local artifact to analyze.", + ), command: z.string().min(1).describe("One caller-selected Rizin command."), }), options: z.object({ @@ -407,7 +406,9 @@ export const registerReverseEngineeringCommands = ( description: "Run one unrestricted Rizin debugger command in an ephemeral session", args: z.object({ - path: z.string().min(1).describe("Path to the local artifact to debug."), + path: localPathStringSchema.describe( + "Path to the local artifact to debug.", + ), command: z .string() .min(1) diff --git a/src/cli/signalCommands.ts b/src/cli/signalCommands.ts index af69294c52..114783dc81 100644 --- a/src/cli/signalCommands.ts +++ b/src/cli/signalCommands.ts @@ -61,6 +61,12 @@ export const registerSignalCommands = ( path: ["all"], message: "all and limit are mutually exclusive", }); + }) + .meta({ + not: { + required: ["all", "limit"], + properties: { all: { const: true } }, + }, }), run: ({ options: { all, producerLimits, ...options } }) => withCommandCancellation((signal) => @@ -121,7 +127,13 @@ export const registerSignalCommands = ( .refine( withinSignalRecordingSelectorBudget, signalRecordingSelectorBudgetIssue, - ), + ) + .meta({ + not: { + required: ["all_lines", "line_limit"], + properties: { all_lines: { const: true } }, + }, + }), run: ({ args, options: { all_lines, producerLimits, ...options } }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.decodeSignalRecording, async () => { diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index 6ffd4ca9bd..88f896afbb 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -347,29 +347,45 @@ const registerReferenceSourceCommand = ( cli.command(CLI_COMMANDS.importReferenceSource, { description: "Import a source tree as historical reference only", args: z.object({ - root: z - .string() - .describe("Local source directory to import as historical reference"), - }), - options: z.object({ - excludePaths: z - .string() - .optional() - .describe( - "JSON array of Git-ignore rules to exclude. Rules use Git-ignore syntax, including wildcards, directory suffixes, and negations; they are not literal path matches. An empty array adds no caller exclusions.", - ), - detail: z - .enum(["full", "summary"]) - .default("full") - .describe( - "Complete committed graph or compact historical-source summary", - ), - outlineDepth: cliIntegerArgument(0) - .optional() - .describe( - "Summary outline path depth (default: 2; 0 omits entries); requires --detail summary", - ), + root: localPathStringSchema.describe( + "Local source directory to import as historical reference", + ), }), + options: z + .object({ + excludePaths: z + .string() + .optional() + .describe( + "JSON array of Git-ignore rules to exclude. Rules use Git-ignore syntax, including wildcards, directory suffixes, and negations; they are not literal path matches. An empty array adds no caller exclusions.", + ), + detail: z + .enum(["full", "summary"]) + .default("full") + .describe( + "Complete committed graph or compact historical-source summary", + ), + outlineDepth: cliIntegerArgument(0) + .optional() + .describe( + "Summary outline path depth (default: 2; 0 omits entries); requires --detail summary", + ), + }) + .superRefine((options, context) => { + if (options.outlineDepth !== undefined && options.detail !== "summary") + context.addIssue({ + code: "custom", + path: ["outlineDepth"], + message: "--outline-depth requires --detail summary", + }); + }) + .meta({ + if: { required: ["outlineDepth"] }, + then: { + required: ["detail"], + properties: { detail: { const: "summary" } }, + }, + }), run: ({ args, options }) => logCliCommand( logger, @@ -379,26 +395,6 @@ const registerReferenceSourceCommand = ( options.excludePaths, ); if (!excludePaths.ok) return excludePaths.failure; - if ( - options.outlineDepth !== undefined && - options.detail !== "summary" - ) - return { - error: "Import failed", - ...projectAnalysisError( - new AnalysisInputError( - "import-reference-source", - { userMessage: "--outline-depth requires --detail summary" }, - [ - { - path: ["outlineDepth"], - reason: "invalid_value", - message: "--outline-depth requires --detail summary", - }, - ], - ), - ), - }; const config = parseConfig(environment); if (!config.ok) return { diff --git a/src/contracts/inputObservationPresentation.test.ts b/src/contracts/inputObservationPresentation.test.ts index b74dd19210..a9ff8d5898 100644 --- a/src/contracts/inputObservationPresentation.test.ts +++ b/src/contracts/inputObservationPresentation.test.ts @@ -68,3 +68,321 @@ it.each(["draft-2020-12", "draft-07"] as const)( ).toBe(false); }, ); + +const referenceWideningCases: readonly { + name: string; + schema: Record; + input: unknown; + unchanged?: boolean; +}[] = [ + { + name: "oneOf", + schema: { + definitions: { off: { const: false } }, + properties: { value: { oneOf: [{ $ref: "#/definitions/off" }, {}] } }, + }, + input: { value: true }, + }, + { + name: "not", + schema: { + definitions: { off: { const: false } }, + properties: { value: { not: { $ref: "#/definitions/off" } } }, + }, + input: { value: true }, + }, + { + name: "if", + schema: { + definitions: { off: { const: false } }, + properties: { + value: { if: { $ref: "#/definitions/off" }, then: false, else: true }, + }, + }, + input: { value: true }, + }, + { + name: "chain", + schema: { + definitions: { + off: { const: false }, + alias: { $ref: "#/definitions/off" }, + }, + properties: { value: { not: { $ref: "#/definitions/alias" } } }, + }, + input: { value: true }, + }, + { + name: "recursive affected", + schema: { + definitions: { + node: { + type: "object", + properties: { + flag: { const: false }, + next: { $ref: "#/definitions/node" }, + }, + required: ["flag"], + }, + }, + properties: { value: { not: { $ref: "#/definitions/node" } } }, + }, + input: { value: { flag: true } }, + }, + { + name: "literal target unchanged", + schema: { + default: { predicate: { const: false } }, + properties: { value: { not: { $ref: "#/default/predicate" } } }, + }, + input: { value: true }, + unchanged: true, + }, + { + name: "nested resource unaffected", + schema: { + $id: "https://example.test/root", + definitions: { flag: { const: false } }, + properties: { + value: { + $id: "child", + definitions: { flag: { const: 7 } }, + not: { $ref: "#/definitions/flag" }, + }, + }, + }, + input: { value: 8 }, + unchanged: true, + }, + { + name: "recursive unaffected", + schema: { + definitions: { + node: { + type: "object", + properties: { + flag: { const: 7 }, + next: { $ref: "#/definitions/node" }, + }, + required: ["flag"], + }, + }, + properties: { value: { not: { $ref: "#/definitions/node" } } }, + }, + input: { value: { flag: 8 } }, + unchanged: true, + }, +]; + +it.each( + referenceWideningCases.flatMap((entry) => + (["draft-2020-12", "draft-07"] as const).map((dialect) => ({ + ...entry, + dialect, + })), + ), +)( + "preserves $name through scoped normalization references in $dialect", + ({ name, schema, input, unchanged, dialect }) => { + const canonical: Record = { + $schema: + dialect === "draft-07" + ? "http://json-schema.org/draft-07/schema#" + : "https://json-schema.org/draft/2020-12/schema", + type: "object", + ...schema, + }; + const advertised = presentInputJsonSchema(canonical, () => undefined); + const create = () => + dialect === "draft-07" + ? new Ajv({ strict: false }) + : new Ajv2020({ strict: false }); + expect(create().compile(canonical)(input), `${name} canonical`).toBe(true); + expect(create().compile(advertised)(input), `${name} advertised`).toBe( + true, + ); + expect(advertised.default, `${name} literal data`).toEqual( + canonical.default, + ); + if (unchanged) + expect(advertised.properties, name).toEqual(canonical.properties); + }, +); + +it("keeps an upper contains bound from rejecting canonical items after referenced literal widening", () => { + const canonical = { + type: "object", + definitions: { off: { const: false } }, + properties: { + values: { + type: "array", + contains: { $ref: "#/definitions/off" }, + minContains: 0, + maxContains: 1, + }, + }, + }; + const advertised = presentInputJsonSchema(canonical, () => undefined); + const input = { values: [false, true] }; + expect(new Ajv2020({ strict: false }).compile(canonical)(input)).toBe(true); + expect(new Ajv2020({ strict: false }).compile(advertised)(input)).toBe(true); +}); + +it.each([false, true])( + "preserves conditional evaluation annotations (referenced: %s)", + (referenced) => { + const conditional = { + if: { properties: { flag: { const: false } } }, + then: { properties: { extra: { type: "string" } } }, + }; + const canonical = { + type: "object", + definitions: { conditional }, + ...(referenced ? { $ref: "#/definitions/conditional" } : conditional), + unevaluatedProperties: false, + }; + const advertised = presentInputJsonSchema(canonical, () => undefined); + const input = { flag: false, extra: "kept" }; + expect(new Ajv2020({ strict: false }).compile(canonical)(input)).toBe(true); + expect(new Ajv2020({ strict: false }).compile(advertised)(input)).toBe( + true, + ); + }, +); + +it("retains item annotations when only the unsafe contains maximum leaves", () => { + const canonical = { + type: "object", + properties: { + values: { + type: "array", + contains: { const: false }, + maxContains: 1, + unevaluatedItems: false, + }, + }, + }; + const advertised = presentInputJsonSchema(canonical, () => undefined); + const input = { values: [false] }; + expect(new Ajv2020({ strict: false }).compile(canonical)(input)).toBe(true); + expect(new Ajv2020({ strict: false }).compile(advertised)(input)).toBe(true); + expect(advertised).toHaveProperty("properties.values.contains", { + type: "boolean", + }); + expect(advertised).toHaveProperty( + "properties.values.unevaluatedItems", + false, + ); +}); + +it("keeps unaffected root property annotations strict when a child conditional widens", () => { + const canonical = { + type: "object", + properties: { + child: { + if: { const: false }, + then: { type: "string" }, + else: { type: "boolean" }, + }, + }, + unevaluatedProperties: false, + }; + const advertised = presentInputJsonSchema(canonical, () => undefined); + const validate = new Ajv2020({ strict: false }).compile(advertised); + expect(validate({ child: true })).toBe(true); + expect(validate({ child: true, unknown: "reject" })).toBe(false); + expect(advertised.unevaluatedProperties).toBe(false); +}); + +it.each(["draft-07", "draft-2020-12"] as const)( + "observes reference sibling semantics in %s", + (dialect) => { + const canonical = { + $schema: + dialect === "draft-07" + ? "http://json-schema.org/draft-07/schema#" + : "https://json-schema.org/draft/2020-12/schema", + type: "object", + definitions: { + number: { type: "number" }, + alias: { $ref: "#/definitions/number", const: false }, + }, + properties: { value: { not: { $ref: "#/definitions/alias" } } }, + }; + const advertised = presentInputJsonSchema(canonical, () => undefined); + const create = () => + dialect === "draft-07" + ? new Ajv({ strict: false, ignoreKeywordsWithRef: true }) + : new Ajv2020({ strict: false }); + // Under the specification's draft7 rules alias is number; in2020 it accepts no value. + const input = { value: "kept" }; + expect(create().compile(canonical)(input)).toBe(true); + expect(create().compile(advertised)(input)).toBe(true); + if (dialect === "draft-07") + expect(advertised.properties).toEqual(canonical.properties); + }, +); + +it.each(["draft-07", "draft-2020-12"] as const)( + "reduces only an affected reference to opaque data in %s", + (dialect) => { + const literal = { + conditional: { + if: { $ref: "#/definitions/off" }, + then: false, + else: true, + }, + unaffected: { type: "string", minLength: 2 }, + }; + const canonical = { + $schema: + dialect === "draft-07" + ? "http://json-schema.org/draft-07/schema#" + : "https://json-schema.org/draft/2020-12/schema", + type: "object", + definitions: { off: { const: false } }, + default: literal, + properties: { + value: { $ref: "#/default/conditional" }, + stable: { $ref: "#/default/unaffected" }, + }, + unevaluatedProperties: false, + }; + const advertised = presentInputJsonSchema(canonical, () => undefined); + const create = () => + dialect === "draft-07" + ? new Ajv({ strict: false }) + : new Ajv2020({ strict: false }); + const input = { value: true, stable: "kept" }; + expect(create().compile(canonical)(input)).toBe(true); + const validate = create().compile(advertised); + expect(validate(input)).toBe(true); + expect(validate({ value: true, stable: 1 })).toBe(false); + expect(advertised.default).toEqual(literal); + expect(advertised).toHaveProperty( + "properties.stable", + canonical.properties.stable, + ); + expect(advertised).toHaveProperty( + "properties.value.description", + expect.stringContaining("reduced form"), + ); + expect(advertised.unevaluatedProperties).toBe(false); + }, +); + +it("does not treat unused definitions as validation dependencies", () => { + const canonical = { + type: "object", + properties: { + value: { + not: { type: "number", definitions: { unused: { const: false } } }, + }, + }, + }; + const advertised = presentInputJsonSchema(canonical, () => undefined); + expect(advertised).toHaveProperty("properties.value.not.type", "number"); + const validate = new Ajv2020({ strict: false }).compile(advertised); + expect(validate({ value: "kept" })).toBe(true); + expect(validate({ value: 1 })).toBe(false); +}); diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts index 01bfd83f4b..cc97088075 100644 --- a/src/contracts/inputSchemaPresentation.ts +++ b/src/contracts/inputSchemaPresentation.ts @@ -196,25 +196,194 @@ const preserveSharedDefault = ( schema.default = defaults[0]; }; -const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { - if (!isObject(value)) return { schema: value, changed: false }; +/** Schema children, excluding definitions when following validation dependencies. */ +const normalizationChildren = ( + schema: Readonly>, + definitions: boolean, +): unknown[] => + Object.entries(schema).flatMap(([key, value]) => { + if (schemaMaps.has(key) && isObject(value)) + return !definitions && (key === "$defs" || key === "definitions") + ? [] + : Object.values(value); + if (schemaArrays.has(key) && Array.isArray(value)) return value; + if (schemaChildren.has(key)) return Array.isArray(value) ? value : [value]; + return []; + }); +const hasNormalizedLiteral = (schema: Readonly>) => + typeof schema.const === "boolean" || + schema.const === "" || + (Array.isArray(schema.enum) && + ((schema.enum.length > 0 && + schema.enum.every((item) => typeof item === "boolean")) || + schema.enum.includes(""))); + +/** One projection owns actual rewrite sites and their scoped validation dependencies. */ +const normalizationContext = (root: Readonly>) => { + const references = new JsonSchemaReferences(root); + type Fact = { + changed: boolean; + lostEvaluation: boolean; + dependents: Set; + evaluationDependents: Set; + opaqueReference?: Fact; + }; + const sites = new Map>(); + const pending: { schema: unknown; scope: JsonSchemaScope | undefined }[] = [ + { schema: root, scope: references.rootScope }, + ]; + const entries: { + schema: Readonly>; + scope: JsonSchemaScope | undefined; + }[] = []; + // Literal payloads are not rewritten, even when a reference evaluates them. + while (pending.length > 0) { + const entry = pending.pop()!; + if (!isObject(entry.schema)) continue; + const scope = references.enter(entry.schema, entry.scope); + let seen = sites.get(scope); + if (seen === undefined) sites.set(scope, (seen = new WeakSet())); + if (seen.has(entry.schema)) continue; + seen.add(entry.schema); + entries.push({ schema: entry.schema, scope }); + for (const schema of normalizationChildren(entry.schema, true)) + pending.push({ schema, scope }); + } + const indexed = new Map>(); + const work: { + schema: Readonly>; + scope: JsonSchemaScope | undefined; + fact: Fact; + }[] = []; + const changed: Fact[] = []; + const factAt = ( + schema: unknown, + inherited: JsonSchemaScope | undefined, + ): Fact | undefined => { + if (!isObject(schema)) return undefined; + const scope = references.enter(schema, inherited); + let facts = indexed.get(scope); + if (facts === undefined) indexed.set(scope, (facts = new WeakMap())); + const existing = facts.get(schema); + if (existing !== undefined) return existing; + const fact: Fact = { + changed: false, + lostEvaluation: false, + dependents: new Set(), + evaluationDependents: new Set(), + }; + facts.set(schema, fact); + work.push({ schema, scope, fact }); + return fact; + }; + for (const { schema, scope } of entries) factAt(schema, scope); + for (let index = 0; index < work.length; index++) { + const { schema, scope, fact } = work[index]!; + const ignoresSiblings = + scope?.dialect === "draft-07" && typeof schema.$ref === "string"; + if ( + !ignoresSiblings && + sites.get(scope)?.has(schema) && + hasNormalizedLiteral(schema) + ) { + fact.changed = true; + changed.push(fact); + } + if (typeof schema.$ref === "string") { + const target = references.resolve(scope, schema.$ref); + if (target.kind === "target") { + const dependency = factAt(target.value, scope); + dependency?.dependents.add(fact); + dependency?.evaluationDependents.add(fact); + if ( + dependency !== undefined && + isObject(target.value) && + !sites.get(references.enter(target.value, scope))?.has(target.value) + ) + fact.opaqueReference = dependency; + } + } + if (!ignoresSiblings) + for (const child of normalizationChildren(schema, false)) + factAt(child, scope)?.dependents.add(fact); + if (!ignoresSiblings) + for (const key of [ + "allOf", + "anyOf", + "oneOf", + "if", + "then", + "else", + "dependentSchemas", + "dependencies", + ]) { + const value = schema[key]; + const children = Array.isArray(value) + ? value + : schemaMaps.has(key) && isObject(value) + ? Object.values(value) + : [value]; + for (const child of children) + factAt(child, scope)?.evaluationDependents.add(fact); + } + } + for (let index = 0; index < changed.length; index++) + for (const dependent of changed[index]!.dependents) + if (!dependent.changed) { + dependent.changed = true; + changed.push(dependent); + } + const lost: Fact[] = []; + for (const { schema, scope, fact } of work) + if ( + sites.get(scope)?.has(schema) && + (fact.opaqueReference?.changed || + (!(scope?.dialect === "draft-07" && typeof schema.$ref === "string") && + factAt(schema.if, scope)?.changed)) + ) { + fact.lostEvaluation = true; + lost.push(fact); + } + for (let index = 0; index < lost.length; index++) + for (const dependent of lost[index]!.evaluationDependents) + if (!dependent.lostEvaluation) { + dependent.lostEvaluation = true; + lost.push(dependent); + } + return { + references, + changed: (schema: object, scope: JsonSchemaScope | undefined) => + indexed.get(scope)?.get(schema)?.changed === true, + lostEvaluation: (schema: object, scope: JsonSchemaScope | undefined) => + indexed.get(scope)?.get(schema)?.lostEvaluation === true, + reduceReference: (schema: object, scope: JsonSchemaScope | undefined) => + indexed.get(scope)?.get(schema)?.opaqueReference?.changed === true, + }; +}; + +const normalizeAdvertisedSchema = ( + value: unknown, + context?: ReturnType, + inherited?: JsonSchemaScope, +): SchemaNormalization => { + if (!isObject(value)) return { schema: value, changed: false }; + const owner = context ?? normalizationContext(value); + const scope = owner.references.enter( + value, + context === undefined ? owner.references.rootScope : inherited, + ); + const normalize = (child: unknown) => + normalizeAdvertisedSchema(child, owner, scope); const node: Record = { ...value }; - let changed = false; + const changed = owner.changed(value, scope); const normalizeMap = (map: Readonly>) => Object.fromEntries( - Object.entries(map).map(([name, child]) => { - const result = normalizeAdvertisedSchema(child); - changed ||= result.changed; - return [name, result.schema]; - }), + Object.entries(map).map(([name, child]) => [ + name, + normalize(child).schema, + ]), ); - const normalizeList = (list: readonly unknown[]) => - list.map((child) => { - const result = normalizeAdvertisedSchema(child); - changed ||= result.changed; - return result; - }); for (const [key, child] of Object.entries(value)) { if (schemaMaps.has(key) && isObject(child)) { @@ -225,7 +394,7 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { key !== "oneOf" && Array.isArray(child) ) { - node[key] = normalizeList(child).map((result) => result.schema); + node[key] = child.map(normalize).map((result) => result.schema); } else if ( schemaChildren.has(key) && key !== "not" && @@ -234,23 +403,20 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { key !== "else" ) { const results = Array.isArray(child) - ? normalizeList(child) - : [normalizeAdvertisedSchema(child)]; + ? child.map(normalize) + : [normalize(child)]; const nestedChanged = results.some((result) => result.changed); if ( key === "contains" && nestedChanged && value.maxContains !== undefined ) { - delete node.contains; - delete node.minContains; delete node.maxContains; - changed = true; + node.contains = results[0]?.schema; } else { node[key] = Array.isArray(child) ? results.map((result) => result.schema) : results[0]?.schema; - changed ||= nestedChanged; } } } @@ -258,12 +424,10 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { if (typeof node.const === "boolean") { delete node.const; if (node.type === undefined) node.type = "boolean"; - changed = true; } if (node.const === "") { delete node.const; if (node.type === undefined) node.type = "string"; - changed = true; } if ( Array.isArray(node.enum) && @@ -272,7 +436,6 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { ) { delete node.enum; if (node.type === undefined) node.type = "boolean"; - changed = true; } if (Array.isArray(node.enum) && node.enum.includes("")) { // Removing only the empty member would reject a canonical-valid input. @@ -284,32 +447,29 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { values.every((item) => typeof item === "string") ) node.type = "string"; - changed = true; } const negated = Object.hasOwn(value, "not") - ? normalizeAdvertisedSchema(value.not) + ? normalize(value.not) : undefined; if (negated?.changed) { // Widening a predicate under `not` narrows the containing schema. - changed = true; delete node.not; } else if (negated !== undefined) { node.not = negated.schema; } const conditional = Object.hasOwn(value, "if") - ? normalizeAdvertisedSchema(value.if) + ? normalize(value.if) : undefined; const thenBranch = Object.hasOwn(value, "then") - ? normalizeAdvertisedSchema(value.then) + ? normalize(value.then) : undefined; const elseBranch = Object.hasOwn(value, "else") - ? normalizeAdvertisedSchema(value.else) + ? normalize(value.else) : undefined; if (conditional?.changed) { // Changing the predicate can route canonical-valid inputs into a rejecting branch. - changed = true; delete node.if; delete node.then; delete node.else; @@ -317,18 +477,16 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { if (conditional !== undefined) node.if = conditional.schema; if (thenBranch !== undefined) { node.then = thenBranch.schema; - changed ||= thenBranch.changed; } if (elseBranch !== undefined) { node.else = elseBranch.schema; - changed ||= elseBranch.changed; } } for (const combinator of ["anyOf", "oneOf"] as const) { const branches = value[combinator]; if (!Array.isArray(branches)) continue; - const normalized = normalizeList(branches); + const normalized = branches.map(normalize); const schemas = normalized.map((result) => result.schema); const branchesChanged = normalized.some((result) => result.changed); if (combinator === "oneOf" && branchesChanged) { @@ -337,7 +495,6 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { node.type = "boolean"; preserveSharedDefault(node, schemas); delete node.oneOf; - changed = true; continue; } // `anyOf` retains branch guidance without allowing widened branches to @@ -349,7 +506,6 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { } else { node.anyOf = schemas; } - changed = true; continue; } if ( @@ -360,13 +516,43 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => { node.type = "boolean"; preserveSharedDefault(node, schemas); delete node[combinator]; - changed = true; continue; } node[combinator] = schemas; - changed ||= branchesChanged; } + if (owner.lostEvaluation(value, scope)) { + delete node.unevaluatedProperties; + delete node.unevaluatedItems; + } + if (owner.reduceReference(value, scope)) { + // The target is also caller literal data. Keep that data intact and relax + // this affected reference rather than copy or mutate its executable view. + if (scope?.dialect === "draft-07") { + for (const key of Object.keys(node)) + if ( + ![ + "$id", + "$schema", + "$anchor", + "$defs", + "definitions", + "description", + "title", + "default", + "examples", + "$comment", + ].includes(key) + ) + delete node[key]; + } else delete node.$ref; + node.description = [ + node.description, + "This referenced input schema is advertised in reduced form; the server validates the complete canonical input.", + ] + .filter((text) => typeof text === "string") + .join(" "); + } return { schema: node, changed }; }; diff --git a/src/domain/captures/packetCapture.ts b/src/domain/captures/packetCapture.ts index e64b35d88a..bfb51a654a 100644 --- a/src/domain/captures/packetCapture.ts +++ b/src/domain/captures/packetCapture.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { processArgumentSchema } from "../process/processArgument.js"; import { fixedTupleSchema } from "../fixedTupleSchema.js"; import { boundedProducerLimitsSchema, @@ -9,11 +10,7 @@ import { localPathStringSchema, } from "../localPath.js"; -const argument = z - .string() - .min(1) - .max(8192) - .refine((value) => !value.includes("\0"), "NUL is not an argument character"); +const argument = processArgumentSchema.min(1).max(8192); const captureInputShape = z.strictObject({ producer_limits: boundedProducerLimitsSchema, path: localPathStringSchema.describe("Absolute local capture path"), diff --git a/src/domain/il2cpp/il2cppMetadata.ts b/src/domain/il2cpp/il2cppMetadata.ts index e8e88df6a0..7366458f77 100644 --- a/src/domain/il2cpp/il2cppMetadata.ts +++ b/src/domain/il2cpp/il2cppMetadata.ts @@ -89,34 +89,58 @@ const optionalIndex = z.number().int().min(-1); const indexWidth = z.union([z.literal(1), z.literal(2), z.literal(4)]); /** One explicit local file; detail selects delivery, never decoding coverage. */ -export const inspectIl2cppMetadataInputSchema = z.strictObject({ - path: absoluteLocalPathStringSchema.describe( - "Absolute filesystem path to a Unity IL2CPP global-metadata.dat file", - ), - detail: z - .enum(["complete", "summary"]) - .default("summary") - .describe( - "summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type, method, member, parameter and string-literal row inline", +export const inspectIl2cppMetadataInputSchema = z + .strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to a Unity IL2CPP global-metadata.dat file", ), - binary_path: absoluteLocalPathStringSchema - .optional() - .describe( - "Absolute path to the caller-selected matching 64-bit IL2CPP binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so); static reading, never executed", - ), - binary_architecture: z - .enum(["arm64", "amd64"]) - .optional() - .describe( - "Slice to read from a universal Mach-O binary_path; required for universal binaries and rejected otherwise", - ), - generic_instances: z - .boolean() - .optional() - .describe( - "Also link generic method instances (genericMethodTable rows) to code addresses with Il2CppDumper-style Type$$Method names; needs binary_path", - ), -}); + detail: z + .enum(["complete", "summary"]) + .default("summary") + .describe( + "summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type, method, member, parameter and string-literal row inline", + ), + binary_path: absoluteLocalPathStringSchema + .optional() + .describe( + "Absolute path to the caller-selected matching 64-bit IL2CPP binary (Mach-O GameAssembly.dylib, PE GameAssembly.dll or ELF libil2cpp.so); static reading, never executed", + ), + binary_architecture: z + .enum(["arm64", "amd64"]) + .optional() + .describe( + "Slice to read from a universal Mach-O binary_path; required for universal binaries and rejected otherwise", + ), + generic_instances: z + .boolean() + .optional() + .describe( + "Also link generic method instances (genericMethodTable rows) to code addresses with Il2CppDumper-style Type$$Method names; needs binary_path", + ), + }) + .superRefine((input, context) => { + if (input.binary_path !== undefined) return; + if (input.binary_architecture !== undefined) + context.addIssue({ + code: "custom", + path: ["binary_architecture"], + message: "binary_architecture selects a slice of binary_path.", + }); + if (input.generic_instances === true) + context.addIssue({ + code: "custom", + path: ["generic_instances"], + message: "generic_instances links generic methods in binary_path.", + }); + }) + .meta({ + dependentRequired: { binary_architecture: ["binary_path"] }, + if: { + properties: { generic_instances: { const: true } }, + required: ["generic_instances"], + }, + then: { required: ["binary_path"] }, + }); export type IL2cppMetadataSectionName = z.infer< typeof il2cppMetadataSectionSchema diff --git a/src/domain/localPath.test.ts b/src/domain/localPath.test.ts index 073f6cc74f..e03a49dbef 100644 --- a/src/domain/localPath.test.ts +++ b/src/domain/localPath.test.ts @@ -64,3 +64,16 @@ it("keeps the Windows absolute-path pattern valid in every client mode", () => { ]), ); }); + +it("rejects unpaired surrogates while preserving representable Unicode paths", () => { + for (const path of ["/tmp/\ud800", "/tmp/\udc00", "/tmp/\ud800x"]) { + expect(absoluteLocalPathStringSchema.safeParse(path).success, path).toBe( + false, + ); + } + for (const path of ["/tmp/😀", "/tmp/�", "/tmp/ spaced name "]) { + expect(absoluteLocalPathStringSchema.safeParse(path).success, path).toBe( + true, + ); + } +}); diff --git a/src/domain/localPath.ts b/src/domain/localPath.ts index 8b4a109fd0..24f1f60e3b 100644 --- a/src/domain/localPath.ts +++ b/src/domain/localPath.ts @@ -16,8 +16,13 @@ export const nulFreeStringSchema = z .string() .regex(/^[^\x00]*$/u, "Local filesystem paths cannot contain NUL"); -/** A selected local filesystem path: nonempty and free of NUL bytes. */ -export const localPathStringSchema = nulFreeStringSchema.min(1); +/** A selected local filesystem path: nonempty, NUL-free, and representable without replacement. */ +export const localPathStringSchema = nulFreeStringSchema + .min(1) + .regex( + /^[^\uD800-\uDFFF]*$/u, + "Local filesystem paths must contain well-formed Unicode", + ); /** * A Windows absolute path: rooted, UNC, or drive-qualified. The classes spell diff --git a/src/domain/native/nativeProcedureInput.ts b/src/domain/native/nativeProcedureInput.ts index 31dacbce24..9697b95098 100644 --- a/src/domain/native/nativeProcedureInput.ts +++ b/src/domain/native/nativeProcedureInput.ts @@ -2,5 +2,5 @@ import { z } from "zod"; /** Selected procedure for function dossier and native API analysis. */ export const nativeProcedureInputSchema = z.strictObject({ - procedure: z.string().describe("A procedure name or address"), + procedure: z.string().min(1).describe("A procedure name or address"), }); diff --git a/src/domain/process/processArgument.ts b/src/domain/process/processArgument.ts new file mode 100644 index 0000000000..d75f29cdd8 --- /dev/null +++ b/src/domain/process/processArgument.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +/** One process argument; NUL cannot be represented by process launch APIs. */ +export const processArgumentSchema = z + .string() + .regex(/^[^\x00]*$/u, "NUL is not an argument character"); diff --git a/src/domain/signals/signalRecording.ts b/src/domain/signals/signalRecording.ts index 314cda1d90..08e7b3cfbc 100644 --- a/src/domain/signals/signalRecording.ts +++ b/src/domain/signals/signalRecording.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { processArgumentSchema } from "../process/processArgument.js"; import { absoluteLocalPathStringSchema, localPathStringSchema, @@ -10,11 +11,7 @@ import { } from "../producerResourceLimits.js"; // These strings are single sigrok arguments, never shell fragments. -const selection = z - .string() - .min(1) - .max(8192) - .refine((value) => !value.includes("\0"), "NUL is not an argument character"); +const selection = processArgumentSchema.min(1).max(8192); const signalInputShape = z.strictObject({ producer_limits: boundedProducerLimitsSchema, path: localPathStringSchema.describe("Absolute local recording path"), diff --git a/tests/boundary/cli/analysisSelectorCli.test.ts b/tests/boundary/cli/analysisSelectorCli.test.ts index 467806bea0..afe35c34bd 100644 --- a/tests/boundary/cli/analysisSelectorCli.test.ts +++ b/tests/boundary/cli/analysisSelectorCli.test.ts @@ -189,3 +189,34 @@ cliTest( } }, ); + +cliTest( + "validates native operation inputs before opening a target", + async ({ cli }) => { + for (const [selection, code] of [ + [ + ["inspect-native-instruction", "missing-target", ""], + "VALIDATION_ERROR", + ], + [ + ["resolve-native-call-targets", "missing-target", ""], + "VALIDATION_ERROR", + ], + [["annotate-native-function", "missing-target", ""], "VALIDATION_ERROR"], + [ + ["annotate-native-function", "missing-target", "main"], + "invalid_request", + ], + [ + ["annotate-native-function", "missing-target", "main", "--comment="], + "target_unavailable", + ], + ] as const) { + const result = await cli.run({ + arguments: [...selection, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(result.json, JSON.stringify(selection)).toMatchObject({ code }); + } + }, +); diff --git a/tests/boundary/cli/cliDomainFieldSchema.test.ts b/tests/boundary/cli/cliDomainFieldSchema.test.ts index 6f953dcd55..dfb844ebc3 100644 --- a/tests/boundary/cli/cliDomainFieldSchema.test.ts +++ b/tests/boundary/cli/cliDomainFieldSchema.test.ts @@ -27,9 +27,11 @@ const serve = async (cli: CliInstance, argv: readonly string[]) => { .object({ args: z .object({ properties: z.record(z.string(), z.unknown()) }) + .passthrough() .optional(), options: z .object({ properties: z.record(z.string(), z.unknown()) }) + .passthrough() .optional(), }) .parse(JSON.parse(stdout)); @@ -163,3 +165,115 @@ it("advertises the domain constraints for android, adb settings, and browser sel parser.args.safeParse({ path: "app.apk", className: "" }).success, ).toBe(false); }); + +it("advertises selection dependencies after omittable-default projection", async () => { + const cli = createCli({}); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + for (const [command, invalid, valid] of [ + [ + "inspect-packet-fields", + { all: true, limit: 1 }, + { all: false, limit: 1 }, + ], + [ + "inspect-signal-decoders", + { all: true, limit: 1 }, + { all: false, limit: 1 }, + ], + [ + "inspect-packet-capture", + { fields: ["ip.src"], all_matches: true, match_limit: 1 }, + { fields: ["ip.src"], all_matches: false, match_limit: 1 }, + ], + [ + "decode-signal-recording", + { decoders: "uart", all_lines: true, line_limit: 1 }, + { decoders: "uart", all_lines: false, line_limit: 1 }, + ], + [ + "inspect-il2cpp-metadata", + { genericInstances: true }, + { genericInstances: false }, + ], + [ + "inspect-il2cpp-metadata", + { architecture: "arm64" }, + { binary: "lib.dylib", architecture: "arm64" }, + ], + [ + "inspect-il2cpp-metadata", + { ghidraSeeds: "seeds.txt" }, + { binary: "lib.so", ghidraSeeds: "seeds.txt" }, + ], + [ + "inspect-il2cpp-metadata", + { seedBase: "0x1000" }, + { binary: "lib.so", ghidraSeeds: "seeds.txt", seedBase: "0x1000" }, + ], + [ + "import-reference-source", + { outlineDepth: 2, detail: "full" }, + { outlineDepth: 2, detail: "summary" }, + ], + ] as const) { + const schema = (await serve(cli, [command, "--schema", "--json"])).options; + expect(schema, command).toBeDefined(); + const validate = ajv.compile(schema as AnySchema); + expect( + validate(invalid), + `${command} invalid ${JSON.stringify(invalid)}`, + ).toBe(false); + expect(validate(valid), `${command} valid ${JSON.stringify(valid)}`).toBe( + true, + ); + } +}); + +it("rejects invalid selected paths before inspecting a target", async () => { + const cli = createCli({}); + for (const [args, field, code] of [ + [ + ["inspect-apple-ips", "missing.ips", "--join-path="], + "joinPath", + "too_small", + ], + [ + ["match-native-functions", "missing.json", "--evidence_output="], + "evidence_output", + "too_small", + ], + [ + ["match-native-functions", "missing.json", "--evidence_output=bad\0path"], + "evidence_output", + "invalid_format", + ], + [ + [ + "match-native-functions", + "missing.json", + "--evidence_output=bad\ud800path", + ], + "evidence_output", + "invalid_format", + ], + [ + ["match-native-functions", "missing.json", "--source_bundle="], + "source_bundle.0", + "too_small", + ], + [["import-reference-source", ""], "root", "too_small"], + ] as const) { + let stdout = ""; + await cli.serve([...args, "--json"], { + env: {}, + exit: () => {}, + stdout: (text) => { + stdout += text; + }, + }); + expect(JSON.parse(stdout), args.join(" ")).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [{ path: field, code }], + }); + } +}); diff --git a/tests/boundary/cli/cliInputSchemaRequired.test.ts b/tests/boundary/cli/cliInputSchemaRequired.test.ts index d803bc06ce..439f133a88 100644 --- a/tests/boundary/cli/cliInputSchemaRequired.test.ts +++ b/tests/boundary/cli/cliInputSchemaRequired.test.ts @@ -703,15 +703,27 @@ describe("reference-source outline-depth input schema", () => { [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER], ] as const; for (const [token, expected] of parsedValues) { - const parsed = parser?.safeParse({ outlineDepth: token }); + const parsed = parser?.safeParse({ + outlineDepth: token, + detail: "summary", + }); expect(parsed?.success).toBe(true); if (parsed === undefined || !parsed.success) throw new Error(`Expected outline-depth token ${token} to parse`); - expect(parsed.data).toMatchObject({ outlineDepth: expected }); + expect(parsed.data).toMatchObject({ + detail: "summary", + outlineDepth: expected, + }); } for (const token of ["", "-1", "9007199254740992"]) { - expect(parser?.safeParse({ outlineDepth: token }).success).toBe(false); + expect( + parser?.safeParse({ outlineDepth: token, detail: "summary" }).success, + ).toBe(false); } + expect(parser?.safeParse({ outlineDepth: "2" }).success).toBe(false); + expect( + parser?.safeParse({ outlineDepth: "2", detail: "full" }).success, + ).toBe(false); const ajv = new Ajv2020({ strict: false }); const validate = ajv.compile({ @@ -719,13 +731,18 @@ describe("reference-source outline-depth input schema", () => { $schema: "https://json-schema.org/draft/2020-12/schema", }); for (const [token] of parsedValues) { - const parsed = parser?.safeParse({ outlineDepth: token }); + const parsed = parser?.safeParse({ + outlineDepth: token, + detail: "summary", + }); if (parsed === undefined || !parsed.success) throw new Error(`Expected outline-depth token ${token} to parse`); expect(validate(parsed.data)).toBe(true); } expect(validate({ outlineDepth: -1 })).toBe(false); expect(validate({ outlineDepth: 9007199254740992 })).toBe(false); + expect(validate({ outlineDepth: 2 })).toBe(false); + expect(validate({ outlineDepth: 2, detail: "full" })).toBe(false); }); }); diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 6e64402213..bcc7bc3c72 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -1897,3 +1897,20 @@ it("refuses a declared DOS relocation header that is not present in the file", a error: { reason: "truncated DOS MZ load module" }, }); }); + +it("does not resolve malformed Unicode paths to a replacement-character sibling", async () => { + const directory = await createTestTempDirectory("rea-target-unicode-"); + const actual = join(directory, "target-�"); + const selected = join(directory, "target-\ud800"); + await writeFile(actual, pe(0xaa64)); + const refused = await parseBinaryTarget(selected); + expect(refused).toMatchObject({ + ok: false, + error: { + _tag: "BinaryTargetError", + path: selected, + reason: "Local filesystem paths must contain well-formed Unicode", + }, + }); + expect(await parseBinaryTarget(actual)).toMatchObject({ ok: true }); +}); diff --git a/tests/composition/directAnalysisCleanup.test.ts b/tests/composition/directAnalysisCleanup.test.ts index 6d2cad61ef..00dc72fa01 100644 --- a/tests/composition/directAnalysisCleanup.test.ts +++ b/tests/composition/directAnalysisCleanup.test.ts @@ -121,6 +121,58 @@ const factories = ( return { dependencies, session }; }; +it("rejects malformed one-shot operation inputs before creating a session", async () => { + const { dependencies, session } = factories(); + const create = vi.fn(() => session); + const guarded = { + ...dependencies, + createBinarySession: create, + createNativeBinarySession: create, + createArtifactBinarySession: create, + createManagedBinarySession: create, + }; + for (const result of [ + await runDirectAnalysis(guarded, "/missing", "inspect_native_instruction", { + address: "", + }), + await runDirectAnalysis(guarded, "/missing", "annotate_native_function", { + procedure: "main", + }), + await runDirectAnalysis(guarded, "/missing", "trace_feature", { + query: "", + }), + await runDirectAnalysis(guarded, "/missing", "analyze_function", { + procedure: "", + }), + await runDirectAnalysis(guarded, "/missing", "inspect_native_api", { + procedure: "", + }), + await runProviderAnalysis(guarded, "/missing", "observe_native_calls", { + breakpoints: [], + }), + await runProviderAnalysis(guarded, "/missing", "inspect_asset_catalog", { + limit: -1, + }), + ]) { + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatchObject({ code: "invalid_request" }); + } + const managed = await runManagedProviderExecution( + guarded, + "/missing", + "inspect_managed_members", + undefined, + { type_names: ["Duplicate", "Duplicate"] }, + ); + expect(managed.ok).toBe(false); + if (!managed.ok) + expect(projectAnalysisError(managed.error)).toMatchObject({ + code: "invalid_request", + }); + expect(create).not.toHaveBeenCalled(); +}); + it.each([false, true])( "retains actual artifact sources and caller path after final-close cancellation (cleanup fails: %s)", async (closeFails) => { @@ -266,9 +318,9 @@ it.each([false, true])( ...(closeFails ? { cleanupError } : {}), }); const result = analysisCliErrorEnvelopeSchema.parse( - await runDirectAnalysis(dependencies, callerPath, "read_bytes", {}).then( - projectCliAnalysisResult, - ), + await runDirectAnalysis(dependencies, callerPath, "read_bytes", { + address: "0x1000", + }).then(projectCliAnalysisResult), ); const observation = parseEvidence( jsonObjectSchema.parse(result.details).partial_observation, @@ -342,7 +394,9 @@ describe("one-shot finalization cancellation", () => { async (tool) => { const [path] = await createBinarySessionTargets(); const parameters = - tool === "analyze_function" ? { procedure: "fixture" } : {}; + tool === "analyze_function" + ? { procedure: "fixture" } + : { address: "0x1000" }; const original = parseEvidence( await runDirectAnalysis( factories().dependencies, @@ -393,7 +447,7 @@ describe("one-shot finalization cancellation", () => { dependencies, path, "read_bytes", - {}, + { address: "0x1000" }, { signal: controller.signal }, ).then(projectCliAnalysisResult); expect(result).toMatchObject({ @@ -420,7 +474,7 @@ describe("one-shot finalization cancellation", () => { dependencies, path, "read_bytes", - {}, + { address: "0x1000" }, { signal: controller.signal }, ).then(projectCliAnalysisResult); expect(result).toMatchObject({ @@ -477,7 +531,7 @@ describe("snapshot publication finalization", () => { dependencies, path, "read_bytes", - {}, + { address: "0x1000" }, { signal: controller.signal, snapshotPath }, ).then(projectCliAnalysisResult); const loaded = await readAnalysisSnapshot(snapshotPath); @@ -539,7 +593,7 @@ describe("snapshot publication finalization", () => { dependencies, path, "read_bytes", - {}, + { address: "0x1000" }, { signal: controller.signal, snapshotPath: failure.path }, ).then(projectCliAnalysisResult), ); @@ -574,9 +628,9 @@ it.each([false, true])( ...(closeFails ? { cleanupError } : {}), }); const result = analysisCliErrorEnvelopeSchema.parse( - await runDirectAnalysis(dependencies, path, "read_bytes", {}).then( - projectCliAnalysisResult, - ), + await runDirectAnalysis(dependencies, path, "read_bytes", { + address: "0x1000", + }).then(projectCliAnalysisResult), ); expect(result).toMatchObject({ code: "cleanup_incomplete", @@ -601,12 +655,9 @@ describe("one-shot analysis cleanup outcomes", () => { it("retains successful evidence and reports CLI failure when close fails", async () => { const [path] = await createBinarySessionTargets(); const { dependencies, session } = factories({ cleanupError }); - const result = await runDirectAnalysis( - dependencies, - path, - "read_bytes", - {}, - ).then(projectCliAnalysisResult); + const result = await runDirectAnalysis(dependencies, path, "read_bytes", { + address: "0x1000", + }).then(projectCliAnalysisResult); expect(result).toMatchObject({ error: "Analysis failed", code: "cleanup_incomplete", @@ -631,12 +682,9 @@ describe("one-shot analysis cleanup outcomes", () => { cleanupError, }); const primary = projectAnalysisError(primaryError); - const result = await runDirectAnalysis( - dependencies, - path, - "read_bytes", - {}, - ).then(projectCliAnalysisResult); + const result = await runDirectAnalysis(dependencies, path, "read_bytes", { + address: "0x1000", + }).then(projectCliAnalysisResult); const callerError = analysisCliErrorEnvelopeSchema.parse(result); expect(callerError).toMatchObject({ code: "cleanup_incomplete", @@ -663,12 +711,9 @@ describe("one-shot analysis cleanup outcomes", () => { const { dependencies } = factories({ thrownCleanup: new Error("unexpected close rejection"), }); - const result = await runDirectAnalysis( - dependencies, - path, - "read_bytes", - {}, - ).then(projectCliAnalysisResult); + const result = await runDirectAnalysis(dependencies, path, "read_bytes", { + address: "0x1000", + }).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: "cleanup_incomplete", details: { @@ -686,9 +731,9 @@ describe("one-shot analysis cleanup outcomes", () => { const thrownExecution = new Error("unexpected execution rejection"); const { dependencies } = factories({ thrownExecution, cleanupError }); await expect( - runDirectAnalysis(dependencies, path, "read_bytes", {}).then( - projectCliAnalysisResult, - ), + runDirectAnalysis(dependencies, path, "read_bytes", { + address: "0x1000", + }).then(projectCliAnalysisResult), ).rejects.toMatchObject({ cause: thrownExecution, errors: [thrownExecution, cleanupError], @@ -698,12 +743,9 @@ describe("one-shot analysis cleanup outcomes", () => { it("keeps successful evidence unchanged after confirmed cleanup", async () => { const [path] = await createBinarySessionTargets(); const { dependencies, session } = factories(); - const result = await runDirectAnalysis( - dependencies, - path, - "read_bytes", - {}, - ).then(projectCliAnalysisResult); + const result = await runDirectAnalysis(dependencies, path, "read_bytes", { + address: "0x1000", + }).then(projectCliAnalysisResult); expect(parseEvidence(result)).toMatchObject({ operation: "read_bytes", normalized_result: { observation: "retained result" }, diff --git a/tests/composition/directAnalysisFactories.test.ts b/tests/composition/directAnalysisFactories.test.ts index a10562967e..786f3886fa 100644 --- a/tests/composition/directAnalysisFactories.test.ts +++ b/tests/composition/directAnalysisFactories.test.ts @@ -65,6 +65,28 @@ const recordingFactories = ( return { dependencies, sessions, clientsClosed: () => clientsClosed }; }; +/** Schema-valid read_bytes input so lifecycle tests reach the session. */ +const admittedReadBytes = { address: "0x1000" } as const; + +/** Schema-valid inputs for the ordinary native session route. */ +const admittedNativeSessionInput = { + observe_native_ui: { pid: 123, window_id: 456 }, + capture_native_ui_scenario: { + pid: 123, + window_id: 456, + steps: [{ kind: "wait", milliseconds: 100 }], + }, + observe_native_calls: { + breakpoints: [ + { + kind: "objc-method", + class_name: "NSURLSession", + selector: "dataTaskWithRequest:completionHandler:", + }, + ], + }, +}; + it("requires a durable summary destination before acquiring a managed session", async () => { const factories = recordingFactories(); const result = await runProviderAnalysis( @@ -125,11 +147,13 @@ describe("one-shot analysis factory boundary", () => { const factories = recordingFactories(failure); const interruptListeners = process.listenerCount("SIGINT"); const terminateListeners = process.listenerCount("SIGTERM"); - await expect( - runDirectAnalysis(factories.dependencies, path, "read_bytes", {}).then( - projectCliAnalysisResult, - ), - ).rejects.toBe(failure); + const execution = runDirectAnalysis( + factories.dependencies, + path, + "read_bytes", + admittedReadBytes, + ).then(projectCliAnalysisResult); + await expect(execution).rejects.toBe(failure); expect(factories.clientsClosed()).toBe(1); expect(process.listenerCount("SIGINT")).toBe(interruptListeners); expect(process.listenerCount("SIGTERM")).toBe(terminateListeners); @@ -247,33 +271,33 @@ describe("one-shot analysis factory boundary", () => { expect(factories.sessions[0]?.activeTarget()).toBeUndefined(); }, ); - - it.each([ - "observe_native_ui", - "capture_native_ui_scenario", - "observe_native_calls", - ] satisfies readonly NativeToolName[])( - "keeps %s on the ordinary session route", - async (tool) => { - const [path] = await createBinarySessionTargets(); - const factories = recordingFactories(); - const result = await runProviderAnalysis( - { - ...factories.dependencies, - createNativeBinarySession: () => { - throw new Error("static inspection factory must stay unused"); - }, - }, - path, - tool, - {}, - ).then(projectCliAnalysisResult); - expect(result).toMatchObject({ operation: tool }); - expect(factories.clientsClosed()).toBe(1); - }, - ); }); +it.each([ + "observe_native_ui", + "capture_native_ui_scenario", + "observe_native_calls", +] satisfies readonly NativeToolName[])( + "keeps %s on the ordinary session route", + async (tool) => { + const [path] = await createBinarySessionTargets(); + const factories = recordingFactories(); + const result = await runProviderAnalysis( + { + ...factories.dependencies, + createNativeBinarySession: () => { + throw new Error("static inspection factory must stay unused"); + }, + }, + path, + tool, + admittedNativeSessionInput[tool], + ).then(projectCliAnalysisResult); + expect(result).toMatchObject({ operation: tool }); + expect(factories.clientsClosed()).toBe(1); + }, +); + it("preserves a managed execution profile in direct analysis Evidence", async () => { const [path] = await createBinarySessionTargets(); const profile = createAnalysisProfile( @@ -307,7 +331,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover factories.dependencies, path, "read_bytes", - {}, + admittedReadBytes, { snapshotPath }, ).then(projectCliAnalysisResult), ).not.toHaveProperty("code"); @@ -315,7 +339,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover factories.dependencies, other, "read_bytes", - {}, + admittedReadBytes, { snapshotPath }, ).then(projectCliAnalysisResult); const sha = async (input: string) => @@ -344,7 +368,7 @@ it("direct analysis snapshot target mismatch retains actionable snapshot recover factories.dependencies, path, "read_bytes", - {}, + admittedReadBytes, { snapshotPath }, ).then(projectCliAnalysisResult), ).not.toHaveProperty("code"); @@ -385,7 +409,7 @@ it("retries snapshot file cleanup before final close even when a published ident dependencies, path, "read_bytes", - {}, + admittedReadBytes, { snapshotPath: join(dirname(path), "excluded.snapshot") }, ).then(projectCliAnalysisResult); expect(events[0]).toBe("file-cleanup"); diff --git a/tests/composition/directAnalysisXrefs.test.ts b/tests/composition/directAnalysisXrefs.test.ts index 8637c24bc3..663f090d31 100644 --- a/tests/composition/directAnalysisXrefs.test.ts +++ b/tests/composition/directAnalysisXrefs.test.ts @@ -101,12 +101,32 @@ const fixture = ( }; describe("direct CLI cross-reference selectors", () => { - it("retains validation issues unrelated to symbol resolution", async () => { + it("rejects unknown arguments before opening a session", async () => { const [path] = await createBinarySessionTargets(); - const issues = [ - { path: ["address"], reason: "invalid_format" }, - { path: ["extra"], reason: "unknown_argument" }, - ] as const; + const provider = fixture({ + xrefsError: new AnalysisInputError("xrefs", undefined, [ + { path: ["address"], reason: "invalid_format" }, + ]), + }); + const result = await runDirectAnalysis( + provider.dependencies, + path, + "xrefs", + { address: "table_only", extra: true }, + ).then(projectCliAnalysisResult); + expect(result).toMatchObject({ + code: "invalid_request", + details: { + operation: "xrefs", + issues: [{ path: ["extra"], reason: "unknown_argument" }], + }, + }); + expect(provider.closed()).toBe(0); + }); + + it("retains provider validation issues unrelated to symbol resolution", async () => { + const [path] = await createBinarySessionTargets(); + const issues = [{ path: ["address"], reason: "invalid_format" }] as const; const provider = fixture({ xrefsError: new AnalysisInputError("xrefs", undefined, issues), }); @@ -114,7 +134,7 @@ describe("direct CLI cross-reference selectors", () => { provider.dependencies, path, "xrefs", - { address: "table_only", extra: true }, + { address: "table_only" }, ).then(projectCliAnalysisResult); expect(result).toMatchObject({ code: "invalid_request",