From 6c35d74a7dbb0ea384274f2a9e5d84b439961614 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:05:05 +0800 Subject: [PATCH] refactor: centralize input validation and schema interpretation Migrate twelve application service files to the existing provider input owner, preserving schema causes, cancellation precedence, caller selection, and retained Evidence. Keep flows with distinct error policies separate. Replace the DeepSeek verifier's recursive object scan with schema-position traversal using the canonical keyword and local-reference owners. Literal pattern fields no longer cause false regex failures; patternProperties keys are checked alongside actual pattern constraints in native regex modes. Validation: 271 focused workflow/service tests and six verifier regressions passed; static checks, discovery, and actual Node source-loader smoke passed. The baseline scanner reproduced the new regressions. Installed DeepSeek client execution was not run. --- scripts/lib/client-schema-patterns.mts | 48 +++++++++ scripts/verify-deepseek-client.mjs | 23 +++-- .../android/AndroidPackageInspection.ts | 29 +++--- .../artifacts/DesktopApplicationService.ts | 34 +++---- .../electron/ElectronFuseService.ts | 35 +++---- src/application/erlang/BeamService.ts | 43 ++++---- src/application/flutter/DartAotInspection.ts | 29 +++--- .../frida/FridaInstrumentationService.ts | 28 +++--- .../il2cpp/Il2cppMetadataService.ts | 32 +++--- .../JavaScriptApplicationWorkflowService.ts | 52 +++++----- .../managed/ManagedReconstructionService.ts | 19 ++-- .../reverse/ReverseEngineeringService.ts | 19 ++-- .../sqlite/SqliteDatabaseService.ts | 33 +++---- src/application/wasm/WasmArtifactService.ts | 43 ++++---- .../verifiers/clientSchemaPatterns.test.ts | 97 +++++++++++++++++++ 15 files changed, 324 insertions(+), 240 deletions(-) create mode 100644 scripts/lib/client-schema-patterns.mts create mode 100644 tests/conformance/verifiers/clientSchemaPatterns.test.ts diff --git a/scripts/lib/client-schema-patterns.mts b/scripts/lib/client-schema-patterns.mts new file mode 100644 index 0000000000..2ac721492a --- /dev/null +++ b/scripts/lib/client-schema-patterns.mts @@ -0,0 +1,48 @@ +import { JsonSchemaReferences } from "../../src/contracts/jsonSchemaReferences.js"; +import { + schemaArrays, + schemaChildren, + schemaMaps, +} from "../../src/contracts/jsonSchemaKeywords.js"; + +const isSchemaObject = ( + value: unknown, +): value is Readonly> => + value !== null && typeof value === "object" && !Array.isArray(value); + +/** Check forwarded regex constraints in native plain/Unicode/Unicode-sets modes. */ +export const assertClientSchemaPatterns = ( + schema: unknown, +): readonly string[] => { + if (!isSchemaObject(schema)) return []; + const patterns: string[] = []; + const seen = new Set(); + const checkPattern = (pattern: string): void => { + for (const flags of ["", "u", "v"]) new RegExp(pattern, flags); + patterns.push(pattern); + }; + const visit = (value: unknown): void => { + if (!isSchemaObject(value) || seen.has(value)) return; + seen.add(value); + for (const [key, child] of Object.entries(value)) { + if (key === "pattern" && typeof child === "string") checkPattern(child); + else if (schemaMaps.has(key) && isSchemaObject(child)) { + for (const [name, nested] of Object.entries(child)) { + if (key === "patternProperties") checkPattern(name); + visit(nested); + } + } else if ( + (schemaArrays.has(key) || schemaChildren.has(key)) && + Array.isArray(child) + ) { + for (const nested of child) visit(nested); + } else if (schemaChildren.has(key)) visit(child); + } + }; + visit(schema); + // Literal instance values stay opaque unless the reference owner resolves a + // local target into them. External/dynamic/ambiguous targets remain unknown. + for (const { target } of new JsonSchemaReferences(schema).references()) + if (target.kind === "target") visit(target.value); + return patterns; +}; diff --git a/scripts/verify-deepseek-client.mjs b/scripts/verify-deepseek-client.mjs index 2a061df833..9b800422f9 100644 --- a/scripts/verify-deepseek-client.mjs +++ b/scripts/verify-deepseek-client.mjs @@ -20,11 +20,17 @@ import { isClientJavaScriptEvidence, } from "./verify/clients/result-discovery.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { importVerifierSource } from "./lib/verifier-source-loader.mjs"; import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; const mode = process.argv[2] ?? "call"; assert(["chat", "call"].includes(mode), "Select chat or call."); assert(process.platform !== "win32", "Native Windows coverage is unverified."); +const { assertClientSchemaPatterns } = await importVerifierSource( + new URL("./lib/client-schema-patterns.mts", import.meta.url).href, + import.meta.url, +); + const repo = fileURLToPath(new URL("..", import.meta.url)); const runtimeRoot = process.env.REA_VERIFY_RUNTIME_ROOT ?? repo; const command = process.env.REA_VERIFY_DEEPSEEK_COMMAND ?? "dsh"; @@ -123,17 +129,6 @@ await withVerifierCleanup(async () => { }); const ajv = new Ajv2020({ strict: false, validateFormats: false }); - const validatePatterns = (value) => { - if (!value || typeof value !== "object") return; - for (const [key, child] of Object.entries(value)) { - if (key === "pattern" && typeof child === "string") { - // Check the representation the native client actually forwards to its model adapter. - for (const flags of ["", "u", "v"]) new RegExp(child, flags); - patterns.add(child); - patternCount++; - } else validatePatterns(child); - } - }; fixture = await createOpenAiModelFixture({ directory: lab, prefix: `deepseek-${mode}`, @@ -160,7 +155,11 @@ await withVerifierCleanup(async () => { ajv.validateSchema(tool.parameters), `${tool.name}: ${ajv.errorsText(ajv.errors)}`, ); - validatePatterns(tool.parameters); + const forwardedPatterns = assertClientSchemaPatterns( + tool.parameters, + ); + for (const pattern of forwardedPatterns) patterns.add(pattern); + patternCount += forwardedPatterns.length; } assert( patternCount > 0, diff --git a/src/application/android/AndroidPackageInspection.ts b/src/application/android/AndroidPackageInspection.ts index 765ac7a988..a879b79727 100644 --- a/src/application/android/AndroidPackageInspection.ts +++ b/src/application/android/AndroidPackageInspection.ts @@ -6,11 +6,10 @@ import { type AnalysisViewRetention, } from "../analysisView/AnalysisViewService.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import { androidPackageInspectionInputSchema } from "../../domain/android/androidAnalysis.js"; import type { Evidence } from "../../domain/evidence.js"; -import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; -import { err, type Result } from "../../domain/result.js"; +import type { Result } from "../../domain/result.js"; +import { parseProviderOperationInput } from "../ProviderOperationExecution.js"; /** Preserve the complete provider request; select retained delivery explicitly. */ export const inspectAndroidPackage = async ( @@ -20,26 +19,22 @@ export const inspectAndroidPackage = async ( options?: ExecutionOptions, ): Promise> => { const operation = "inspect_android_package"; - if (options?.signal?.aborted === true) - return err( - new AnalysisCancelledError(operation, { cause: options.signal.reason }), - ); - const parsed = androidPackageInspectionInputSchema.safeParse(rawInput); - if (!parsed.success) - return err( - analysisInputErrorFromIssues(operation, parsed.error.issues, rawInput, { - cause: parsed.error, - }), - ); - if (parsed.data.detail === "summary") { + const parsed = parseProviderOperationInput( + operation, + rawInput, + androidPackageInspectionInputSchema, + options?.signal, + ); + if (!parsed.ok) return parsed; + if (parsed.value.detail === "summary") { const ready = requireAnalysisViewRetention(operation, retention); if (!ready.ok) return ready; } const executed = await service.execute( operation, - { path: parsed.data.path }, + { path: parsed.value.path }, options, ); - if (!executed.ok || parsed.data.detail !== "summary") return executed; + if (!executed.ok || parsed.value.detail !== "summary") return executed; return summarizeRetainedAnalysis(executed.value, retention); }; diff --git a/src/application/artifacts/DesktopApplicationService.ts b/src/application/artifacts/DesktopApplicationService.ts index 9710936c70..7fd4773043 100644 --- a/src/application/artifacts/DesktopApplicationService.ts +++ b/src/application/artifacts/DesktopApplicationService.ts @@ -1,13 +1,11 @@ import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { DesktopApplicationPort } from "./DesktopApplicationPort.js"; import { ARTIFACT_GRAPH_PROVIDER } from "../../domain/investigationProviders.js"; -import { completeProviderEvidence } from "../ProviderOperationExecution.js"; import { - AnalysisInputError, - AnalysisOutputError, - AnalysisCancelledError, -} from "../../domain/analysisErrorCore.js"; -import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; + completeProviderEvidence, + parseProviderOperationInput, +} from "../ProviderOperationExecution.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import type { Evidence } from "../../domain/evidence.js"; import { createDesktopApplicationEvidence } from "../../domain/desktopApplicationEvidence.js"; import { @@ -29,21 +27,17 @@ export class DesktopApplicationService { options?: ExecutionOptions, ): Promise> { const operation = "inspect_desktop_application"; - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(operation, { cause: options.signal.reason }), - ); - const parsed = inspectDesktopApplicationInputSchema.safeParse(rawInput); - if (!parsed.success) - return err( - analysisInputErrorFromIssues(operation, parsed.error.issues, rawInput, { - cause: parsed.error, - }), - ); - const result = await this.reader.inspect(parsed.data, options); + const parsed = parseProviderOperationInput( + operation, + rawInput, + inspectDesktopApplicationInputSchema, + options?.signal, + ); + if (!parsed.ok) return parsed; + const result = await this.reader.inspect(parsed.value, options); if (!result.ok) return result; const report = desktopApplicationSchema.safeParse(result.value); - if (!report.success || report.data.root.path !== parsed.data.path) + if (!report.success || report.data.root.path !== parsed.value.path) return err( new AnalysisOutputError( operation, @@ -52,7 +46,7 @@ export class DesktopApplicationService { ); return completeProviderEvidence( createDesktopApplicationEvidence( - parsed.data, + parsed.value, report.data, ARTIFACT_GRAPH_PROVIDER, ), diff --git a/src/application/electron/ElectronFuseService.ts b/src/application/electron/ElectronFuseService.ts index dbe5429203..4290ebdf4b 100644 --- a/src/application/electron/ElectronFuseService.ts +++ b/src/application/electron/ElectronFuseService.ts @@ -1,19 +1,18 @@ import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { ElectronFusePort } from "./ElectronFusePort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisCancelledError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { electronFuseReportSchema, inspectElectronFusesInputSchema, } from "../../domain/electron/electronFuses.js"; import type { Evidence } from "../../domain/evidence.js"; import { createElectronFuseEvidence } from "../../domain/electron/electronFuseEvidence.js"; -import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; -import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { + completeProviderEvidence, + parseProviderOperationInput, +} from "../ProviderOperationExecution.js"; const OPERATION = "inspect_electron_fuses"; @@ -31,23 +30,19 @@ export class ElectronFuseService { rawInput: unknown, options?: ExecutionOptions, ): Promise> { - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), - ); - const input = inspectElectronFusesInputSchema.safeParse(rawInput); - if (!input.success) - return err( - analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { - cause: input.error, - }), - ); - const inspected = await this.provider.inspect(input.data, options); + const input = parseProviderOperationInput( + OPERATION, + rawInput, + inspectElectronFusesInputSchema, + options?.signal, + ); + if (!input.ok) return input; + const inspected = await this.provider.inspect(input.value, options); if (!inspected.ok) return inspected; const report = electronFuseReportSchema.safeParse(inspected.value); if ( !report.success || - report.data.artifact.selected_path !== input.data.path + report.data.artifact.selected_path !== input.value.path ) return err( new AnalysisOutputError( @@ -57,7 +52,7 @@ export class ElectronFuseService { ); return completeProviderEvidence( createElectronFuseEvidence( - input.data, + input.value, report.data, this.provider.identity, ), diff --git a/src/application/erlang/BeamService.ts b/src/application/erlang/BeamService.ts index 483ed45798..46fa29a988 100644 --- a/src/application/erlang/BeamService.ts +++ b/src/application/erlang/BeamService.ts @@ -1,19 +1,18 @@ import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { BeamPort } from "./BeamPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisCancelledError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { inspectErlangBeamInputSchema, erlangBeamSchema, type ErlangBeam, } from "../../domain/erlang/beam.js"; -import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, type Result } from "../../domain/result.js"; -import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { + completeProviderEvidence, + parseProviderOperationInput, +} from "../ProviderOperationExecution.js"; const OPERATION = "inspect_erlang_beam"; @@ -29,30 +28,24 @@ export class BeamService { rawInput: unknown, options?: ExecutionOptions, ): Promise> { - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(OPERATION, { - cause: options.signal.reason, - }), - ); - const input = inspectErlangBeamInputSchema.safeParse(rawInput); - if (!input.success) - return err( - analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { - cause: input.error, - }), - ); - const result = await this.provider.inspect(input.data, options); + const input = parseProviderOperationInput( + OPERATION, + rawInput, + inspectErlangBeamInputSchema, + options?.signal, + ); + if (!input.ok) return input; + const result = await this.provider.inspect(input.value, options); if (!result.ok) return result; const report = erlangBeamSchema.safeParse(result.value); if ( !report.success || - report.data.artifact.path !== input.data.path || + report.data.artifact.path !== input.value.path || !validReport( report.data, - input.data.max_chunks, - input.data.max_atoms, - input.data.max_file_bytes, + input.value.max_chunks, + input.value.max_atoms, + input.value.max_file_bytes, ) ) return err( @@ -72,7 +65,7 @@ export class BeamService { this.provider.identity, { operation: OPERATION, - parameters: input.data, + parameters: input.value, result: value, confidence: "observed", limitations: value.limitations, diff --git a/src/application/flutter/DartAotInspection.ts b/src/application/flutter/DartAotInspection.ts index df49188388..ab3deb6388 100644 --- a/src/application/flutter/DartAotInspection.ts +++ b/src/application/flutter/DartAotInspection.ts @@ -6,11 +6,10 @@ import { type AnalysisViewRetention, } from "../analysisView/AnalysisViewService.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import { dartAotInspectionInputSchema } from "../../domain/flutter/flutterBuildAnalysis.js"; import type { Evidence } from "../../domain/evidence.js"; -import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; -import { err, type Result } from "../../domain/result.js"; +import type { Result } from "../../domain/result.js"; +import { parseProviderOperationInput } from "../ProviderOperationExecution.js"; /** Keep the caller's ABI choice and complete analysis; select only its delivery. */ export const inspectDartAot = async ( @@ -20,24 +19,18 @@ export const inspectDartAot = async ( options?: ExecutionOptions, ): Promise> => { const operation = "inspect_dart_aot"; - if (options?.signal?.aborted === true) - return err( - new AnalysisCancelledError(operation, { - cause: options.signal.reason, - }), - ); - const parsed = dartAotInspectionInputSchema.safeParse(rawInput); - if (!parsed.success) - return err( - analysisInputErrorFromIssues(operation, parsed.error.issues, rawInput, { - cause: parsed.error, - }), - ); - if (parsed.data.detail === "summary") { + const parsed = parseProviderOperationInput( + operation, + rawInput, + dartAotInspectionInputSchema, + options?.signal, + ); + if (!parsed.ok) return parsed; + if (parsed.value.detail === "summary") { const ready = requireAnalysisViewRetention(operation, retention); if (!ready.ok) return ready; } - const { detail, ...providerInput } = parsed.data; + const { detail, ...providerInput } = parsed.value; const executed = await service.execute(operation, providerInput, options); if (!executed.ok || detail !== "summary") return executed; return summarizeRetainedAnalysis(executed.value, retention); diff --git a/src/application/frida/FridaInstrumentationService.ts b/src/application/frida/FridaInstrumentationService.ts index b0f52f7c67..08084743ec 100644 --- a/src/application/frida/FridaInstrumentationService.ts +++ b/src/application/frida/FridaInstrumentationService.ts @@ -16,7 +16,7 @@ import { } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; import { fridaSessionStatusInputSchema } from "../../domain/fridaSessionStatus.js"; -import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { parseProviderOperationInput } from "../ProviderOperationExecution.js"; import { requireAnalysisViewRetention, summarizeRetainedAnalysis, @@ -252,36 +252,32 @@ export class FridaInstrumentationService { evidenceById: undefined, }, ): Result { - const input = fridaSessionStatusInputSchema.safeParse(rawInput); - if (!input.success) - return err( - analysisInputErrorFromIssues( - "frida_session_status", - input.error.issues, - rawInput, - { cause: input.error }, - ), - ); - if (input.data.detail === "summary") { + const input = parseProviderOperationInput( + "frida_session_status", + rawInput, + fridaSessionStatusInputSchema, + ); + if (!input.ok) return input; + if (input.value.detail === "summary") { const ready = requireAnalysisViewRetention( "frida_session_status", retention, ); if (!ready.ok) return ready; } - const status = this.#provider.status(input.data.session_id); + const status = this.#provider.status(input.value.session_id); if (status === undefined) return err( - unknownFridaSession("frida_session_status", input.data.session_id), + unknownFridaSession("frida_session_status", input.value.session_id), ); const evidence = fridaEvidence( "frida_session_status", sessionStatusOutput(status), { - session_id: input.data.session_id, + session_id: input.value.session_id, }, ); - return input.data.detail === "summary" + return input.value.detail === "summary" ? summarizeRetainedAnalysis(evidence, retention) : ok(evidence); } diff --git a/src/application/il2cpp/Il2cppMetadataService.ts b/src/application/il2cpp/Il2cppMetadataService.ts index 76cdae05f2..2e8a04ab75 100644 --- a/src/application/il2cpp/Il2cppMetadataService.ts +++ b/src/application/il2cpp/Il2cppMetadataService.ts @@ -1,3 +1,4 @@ +import { parseProviderOperationInput } from "../ProviderOperationExecution.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import type { @@ -25,7 +26,6 @@ import { type Il2cppMetadata, type Il2cppMetadataProviderInput, } from "../../domain/il2cpp/il2cppMetadata.js"; -import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; const OPERATION = "inspect_il2cpp_metadata"; @@ -99,20 +99,16 @@ export class Il2cppMetadataService { retention: AnalysisViewRetention, output?: Il2cppSeedOutput, ): Promise> { - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), - ); - const input = inspectIl2cppMetadataInputSchema.safeParse(rawInput); - if (!input.success) - return err( - analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { - cause: input.error, - }), - ); + const input = parseProviderOperationInput( + OPERATION, + rawInput, + inspectIl2cppMetadataInputSchema, + options?.signal, + ); + if (!input.ok) return input; if ( - input.data.binary_architecture !== undefined && - input.data.binary_path === undefined + input.value.binary_architecture !== undefined && + input.value.binary_path === undefined ) return err( new AnalysisInputError(OPERATION, undefined, [ @@ -124,8 +120,8 @@ export class Il2cppMetadataService { ]), ); if ( - input.data.generic_instances === true && - input.data.binary_path === undefined + input.value.generic_instances === true && + input.value.binary_path === undefined ) return err( new AnalysisInputError(OPERATION, undefined, [ @@ -137,7 +133,7 @@ export class Il2cppMetadataService { ]), ); if (output !== undefined) { - if (input.data.binary_path === undefined) + if (input.value.binary_path === undefined) return err( new AnalysisInputError(OPERATION, undefined, [ { @@ -158,7 +154,7 @@ export class Il2cppMetadataService { const ready = await this.seeds.check(output, options); if (!ready.ok) return ready; } - const { detail, ...providerInput } = input.data; + const { detail, ...providerInput } = input.value; if (detail === "summary") { const ready = requireAnalysisViewRetention(OPERATION, retention); if (!ready.ok) return ready; diff --git a/src/application/javascript/JavaScriptApplicationWorkflowService.ts b/src/application/javascript/JavaScriptApplicationWorkflowService.ts index fe4a4b2880..703dbbb4b9 100644 --- a/src/application/javascript/JavaScriptApplicationWorkflowService.ts +++ b/src/application/javascript/JavaScriptApplicationWorkflowService.ts @@ -3,6 +3,7 @@ import { type EvidenceLookup, } from "../EvidenceInputResolver.js"; import { retainedEvidenceReferenceSchema } from "../../domain/evidenceReference.js"; +import { parseProviderOperationInput } from "../ProviderOperationExecution.js"; import type { ResolvedSourceToBundleReference } from "./SourceToBundleReference.js"; import { z } from "zod"; @@ -121,16 +122,15 @@ export const traceApplicationFeatureEvidence = ( AnalysisError > => { const operation = "trace_application_feature"; - const request = featureTraceFrameSchema.safeParse(rawInput); - if (!request.success) - return err( - analysisInputErrorFromIssues(operation, request.error.issues, rawInput, { - cause: request.error, - }), - ); - const selected = resolveWorkflowEvidence(request.data.application, lookup); + const request = parseProviderOperationInput( + operation, + rawInput, + featureTraceFrameSchema, + ); + if (!request.ok) return request; + const selected = resolveWorkflowEvidence(request.value.application, lookup); if (!selected.ok) return selected; - const input = { ...request.data, application: selected.value }; + const input = { ...request.value, application: selected.value }; const sourceResult = parseApplicationGraphEvidence(input.application, [ "application", ]); @@ -187,17 +187,16 @@ export const compareApplicationVersionsEvidence = ( AnalysisError > => { const operation = "compare_application_versions"; - const request = versionComparisonFrameSchema.safeParse(rawInput); - if (!request.success) - return err( - analysisInputErrorFromIssues(operation, request.error.issues, rawInput, { - cause: request.error, - }), - ); - const pair = resolveApplicationGraphPair(operation, request.data, lookup); + const request = parseProviderOperationInput( + operation, + rawInput, + versionComparisonFrameSchema, + ); + if (!request.ok) return request; + const pair = resolveApplicationGraphPair(operation, request.value, lookup); if (!pair.ok) return pair; const { left, right } = pair.value; - const input = { ...request.data, ...pair.value.selected }; + const input = { ...request.value, ...pair.value.selected }; const leftNativeResult = parseNativeApplicationEvidence( input.left_native_observations, ["left_native_observations"], @@ -306,17 +305,16 @@ export const compareJavaScriptExportShapesEvidence = ( AnalysisError > => { const operation = "compare_javascript_export_shapes"; - const request = exportShapeFrameSchema.safeParse(rawInput); - if (!request.success) - return err( - analysisInputErrorFromIssues(operation, request.error.issues, rawInput, { - cause: request.error, - }), - ); - const pair = resolveApplicationGraphPair(operation, request.data, lookup); + const request = parseProviderOperationInput( + operation, + rawInput, + exportShapeFrameSchema, + ); + if (!request.ok) return request; + const pair = resolveApplicationGraphPair(operation, request.value, lookup); if (!pair.ok) return pair; const { left, right } = pair.value; - const input = { ...request.data, ...pair.value.selected }; + const input = { ...request.value, ...pair.value.selected }; const identities = javaScriptExportShapeIdentitiesSchema.safeParse({ left: left.evidence, right: right.evidence, diff --git a/src/application/managed/ManagedReconstructionService.ts b/src/application/managed/ManagedReconstructionService.ts index 03f5786d7a..70e6323838 100644 --- a/src/application/managed/ManagedReconstructionService.ts +++ b/src/application/managed/ManagedReconstructionService.ts @@ -8,9 +8,9 @@ import { type ManagedReconstructionImportResult, type ResolvedManagedReconstructionImport, } from "../../domain/managed/managedReconstruction.js"; -import { err, ok, type Result } from "../../domain/result.js"; +import { ok, type Result } from "../../domain/result.js"; import { MANAGED_WORKFLOW_PROVIDER } from "../../domain/investigationProviders.js"; -import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; +import { parseProviderOperationInput } from "../ProviderOperationExecution.js"; const OPERATION = "import_managed_reconstruction" as const; @@ -18,14 +18,13 @@ const OPERATION = "import_managed_reconstruction" as const; export const resolveManagedReconstructionEvidenceInput = ( rawInput: unknown, ): Result => { - const parsed = managedReconstructionImportRequestSchema.safeParse(rawInput); - if (!parsed.success) - return err( - analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput, { - cause: parsed.error, - }), - ); - return resolveManagedReconstructionImport(parsed.data); + const parsed = parseProviderOperationInput( + OPERATION, + rawInput, + managedReconstructionImportRequestSchema, + ); + if (!parsed.ok) return parsed; + return resolveManagedReconstructionImport(parsed.value); }; /** Execute from the source facts returned by the resolver. */ diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index a4adcba067..726abb4475 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -47,6 +47,7 @@ import { } from "../../process/OwnedCommand.js"; import type { ProviderProcessSupervisor } from "../../process/ProviderProcess.js"; import { absoluteCallerPath } from "../../filesystem/callerPath.js"; +import { parseProviderOperationInput } from "../ProviderOperationExecution.js"; export type ReverseEngineeringOperation = | "inspect_with_objdump" @@ -214,21 +215,17 @@ export class ReverseEngineeringService { recordEvidence: undefined, }, ): Promise> { - const parsed = objdumpInputSchema.safeParse(rawInput); - if (!parsed.success) - return err( - analysisInputErrorFromIssues( - "inspect_with_objdump", - parsed.error.issues, - rawInput, - { cause: parsed.error }, - ), - ); + const parsed = parseProviderOperationInput( + "inspect_with_objdump", + rawInput, + objdumpInputSchema, + ); + if (!parsed.ok) return parsed; const { detail, command_timeout_ms: commandTimeoutMs, ...input - } = parsed.data; + } = parsed.value; if (detail === "summary") { const ready = requireAnalysisViewRetention( "inspect_with_objdump", diff --git a/src/application/sqlite/SqliteDatabaseService.ts b/src/application/sqlite/SqliteDatabaseService.ts index f1cc74eecd..ba206261d7 100644 --- a/src/application/sqlite/SqliteDatabaseService.ts +++ b/src/application/sqlite/SqliteDatabaseService.ts @@ -1,10 +1,8 @@ +import { parseProviderOperationInput } from "../ProviderOperationExecution.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { SqliteDatabasePort } from "./SqliteDatabasePort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisCancelledError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { createEvidence, type Evidence, @@ -21,7 +19,6 @@ import { sqliteSchemaScope, sqliteInspectionParameters, } from "../../domain/sqlite/sqliteDatabase.js"; -import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, ok, type Result } from "../../domain/result.js"; const OPERATION = "inspect_sqlite_database"; @@ -82,27 +79,21 @@ export class SqliteDatabaseService { rawInput: unknown, options?: ExecutionOptions, ): Promise> { - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(OPERATION, { - cause: options.signal.reason, - }), - ); - const input = inspectSqliteDatabaseInputSchema.safeParse(rawInput); - if (!input.success) - return err( - analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { - cause: input.error, - }), - ); - const inspected = await this.provider.inspect(input.data, options); + const input = parseProviderOperationInput( + OPERATION, + rawInput, + inspectSqliteDatabaseInputSchema, + options?.signal, + ); + if (!input.ok) return input; + const inspected = await this.provider.inspect(input.value, options); if (!inspected.ok) return inspected; const execution = inspected.value; const report = sqliteDatabaseSchema.safeParse(execution.result); if ( !report.success || execution.subject == null || - !matchesSelectedDatabase(report.data, input.data, execution.subject) + !matchesSelectedDatabase(report.data, input.value, execution.subject) ) return err( new AnalysisOutputError( @@ -113,7 +104,7 @@ export class SqliteDatabaseService { return ok( createEvidence(execution.subject, execution.provider, { operation: OPERATION, - parameters: sqliteInspectionParameters(input.data), + parameters: sqliteInspectionParameters(input.value), result: report.data, ...(execution.rawResult === null ? {} diff --git a/src/application/wasm/WasmArtifactService.ts b/src/application/wasm/WasmArtifactService.ts index 24a952dca6..a95ab38014 100644 --- a/src/application/wasm/WasmArtifactService.ts +++ b/src/application/wasm/WasmArtifactService.ts @@ -3,10 +3,7 @@ import type { ExecutionOptions } from "../AnalysisProvider.js"; import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js"; import type { WasmArtifactPort } from "./WasmArtifactPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { - AnalysisCancelledError, - AnalysisOutputError, -} from "../../domain/analysisErrorCore.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import type { Evidence } from "../../domain/evidence.js"; import { inspectWasmArtifactInputSchema, @@ -14,9 +11,11 @@ import { wasmArtifactMatchesSubject, DEFAULT_WASM_COMMAND_TIMEOUT_MS, } from "../../domain/wasm/wasmArtifact.js"; -import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { err, type Result } from "../../domain/result.js"; -import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { + completeProviderEvidence, + parseProviderOperationInput, +} from "../ProviderOperationExecution.js"; const OPERATION = "inspect_wasm_artifact"; @@ -37,24 +36,18 @@ export class WasmArtifactService { rawInput: unknown, options?: ExecutionOptions, ): Promise> { - if (options?.signal?.aborted) - return err( - new AnalysisCancelledError(OPERATION, { - cause: options.signal.reason, - }), - ); - const input = inspectWasmArtifactInputSchema.safeParse(rawInput); - if (!input.success) - return err( - analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, { - cause: input.error, - }), - ); + const input = parseProviderOperationInput( + OPERATION, + rawInput, + inspectWasmArtifactInputSchema, + options?.signal, + ); + if (!input.ok) return input; const timeout = - input.data.command_timeout_ms ?? + input.value.command_timeout_ms ?? this.settings?.().overrides["wasm.command_timeout_ms"] ?? DEFAULT_WASM_COMMAND_TIMEOUT_MS; - const inspected = await this.provider.inspect(input.data, { + const inspected = await this.provider.inspect(input.value, { ...options, commandTimeoutMs: timeout, }); @@ -63,14 +56,14 @@ export class WasmArtifactService { const report = wasmArtifactSchema.safeParse(execution.result); if ( !report.success || - report.data.artifact.path !== input.data.path || + report.data.artifact.path !== input.value.path || report.data.tool_profile.timeout_ms !== timeout || !wasmArtifactMatchesSubject(report.data.artifact, execution.subject) || JSON.stringify(report.data.glue.map((item) => item.artifact.path)) !== - JSON.stringify(input.data.glue_paths) || + JSON.stringify(input.value.glue_paths) || JSON.stringify(report.data.candidates.map((item) => item.path)) !== JSON.stringify([ - ...new Set([input.data.path, ...input.data.candidate_paths]), + ...new Set([input.value.path, ...input.value.candidate_paths]), ]) ) return err( @@ -82,7 +75,7 @@ export class WasmArtifactService { return completeProviderEvidence( createAnalysisExecutionEvidence( OPERATION, - input.data, + input.value, { ...execution, result: report.data }, "observed", ), diff --git a/tests/conformance/verifiers/clientSchemaPatterns.test.ts b/tests/conformance/verifiers/clientSchemaPatterns.test.ts new file mode 100644 index 0000000000..caec43e797 --- /dev/null +++ b/tests/conformance/verifiers/clientSchemaPatterns.test.ts @@ -0,0 +1,97 @@ +import { expect, it } from "vitest"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { assertClientSchemaPatterns } from "../../../scripts/lib/client-schema-patterns.mjs"; + +it("keeps pattern-looking fields inside valid literal values opaque", () => { + const schema = { + type: "object", + properties: { + payload: { + const: { pattern: "[" }, + enum: [{ pattern: "[" }], + default: { pattern: "[" }, + examples: [{ pattern: "[" }], + }, + }, + }; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect(ajv.validateSchema(schema)).toBe(true); + expect(assertClientSchemaPatterns(schema)).toEqual([]); +}); + +it("validates regex keys in patternProperties", () => { + expect(() => + assertClientSchemaPatterns({ + patternProperties: { "[": { type: "string" } }, + }), + ).toThrow(SyntaxError); + expect( + assertClientSchemaPatterns({ + patternProperties: { "^name": { pattern: "value$" } }, + }), + ).toEqual(["^name", "value$"]); +}); + +it("visits nested schema positions owned by the schema keyword catalog", () => { + const schema = { + $defs: { named: { pattern: "^named" } }, + properties: { + value: { + allOf: [ + { contains: { pattern: "^contained" } }, + { + if: { propertyNames: { pattern: "^property" } }, + then: { additionalProperties: { pattern: "^additional" } }, + else: { not: { pattern: "^negated" } }, + }, + ], + }, + }, + prefixItems: [{ anyOf: [{ pattern: "^first" }, { pattern: "^second" }] }], + unevaluatedItems: { pattern: "^unevaluated" }, + }; + expect(assertClientSchemaPatterns(schema)).toEqual([ + "^named", + "^contained", + "^property", + "^additional", + "^negated", + "^first", + "^second", + "^unevaluated", + ]); +}); + +it("treats a literal object as a schema only when a local reference selects it", () => { + const schema = { const: { pattern: "[" } }; + expect(assertClientSchemaPatterns(schema)).toEqual([]); + expect(() => + assertClientSchemaPatterns({ ...schema, $ref: "#/const" }), + ).toThrow(SyntaxError); +}); + +it("uses existing reference resolution without loading external or dynamic targets", () => { + expect( + assertClientSchemaPatterns({ + $ref: "https://example.invalid/schema", + const: { pattern: "[" }, + }), + ).toEqual([]); + expect( + assertClientSchemaPatterns({ + $dynamicRef: "#external", + examples: [{ pattern: "[" }], + }), + ).toEqual([]); +}); + +it("keeps native Unicode-mode failures and repeated pattern occurrences visible", () => { + expect(() => assertClientSchemaPatterns({ pattern: "\\a" })).toThrow( + SyntaxError, + ); + expect( + assertClientSchemaPatterns({ + anyOf: [{ pattern: "^same" }, { pattern: "^same" }], + }), + ).toEqual(["^same", "^same"]); +});