From 719d56ebfeff78acf560d759326d5ba9d20e273e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:02:31 +0800 Subject: [PATCH] fix(evidence): preserve provider execution provenance across workflows --- .../captures/PacketCaptureService.test.ts | 102 ++++++++++++++++++ .../captures/PacketCaptureService.ts | 56 ++++------ .../firmware/FirmwareAnalysisService.test.ts | 25 ++++- .../firmware/FirmwareAnalysisService.ts | 21 ++-- .../signals/SignalRecordingService.test.ts | 96 +++++++++++++++++ .../signals/SignalRecordingService.ts | 56 ++++------ .../wasm/WasmArtifactService.test.ts | 67 ++++++++---- src/application/wasm/WasmArtifactService.ts | 24 ++--- 8 files changed, 334 insertions(+), 113 deletions(-) create mode 100644 src/application/captures/PacketCaptureService.test.ts create mode 100644 src/application/signals/SignalRecordingService.test.ts diff --git a/src/application/captures/PacketCaptureService.test.ts b/src/application/captures/PacketCaptureService.test.ts new file mode 100644 index 0000000000..3cd1521d09 --- /dev/null +++ b/src/application/captures/PacketCaptureService.test.ts @@ -0,0 +1,102 @@ +import { expect, it } from "vitest"; + +import { + createAnalysisExecution, + type ExecutionOptions, +} from "../AnalysisProvider.js"; +import { PacketCaptureService } from "./PacketCaptureService.js"; +import { createAnalysisProfile } from "../../domain/analysisProfile.js"; +import { inspectPacketCaptureInputSchema } from "../../domain/captures/packetCapture.js"; +import { ok } from "../../domain/result.js"; + +it("retains provider metadata in a late-cancellation observation", async () => { + const input = inspectPacketCaptureInputSchema.parse({ + path: process.platform === "win32" ? "C:\\capture.pcap" : "/capture.pcap", + fields: ["ip.addr"], + }); + const provider = { + id: "fixture-capture", + name: "Fixture capture provider", + version: "1", + } as const; + const subject = { + path: input.path, + sha256: "a".repeat(64), + format: "file", + } as const; + const report = { + artifact: { path: input.path, sha256: subject.sha256, bytes: 12 }, + selection: input, + packets: [], + output_window: { + matching_packets: 0, + returned_packets: 0, + has_more: false, + }, + read_scope: "to-eof", + diagnostics: "", + tool: { + configured_path: "/fixture/tshark", + path: "/fixture/tshark", + sha256: "b".repeat(64), + version_banner: "TShark fixture", + version_diagnostics: "", + arguments: [], + }, + configuration: "private-empty-personal-profile", + resource_limiter: { + configured_path: "/fixture/prlimit", + path: "/fixture/prlimit", + sha256: "c".repeat(64), + arguments: [], + }, + limits: { + input_bytes: 1, + output_bytes: 1, + command_timeout_ms: 1, + address_space_bytes: 0, + file_bytes: 0, + cpu_seconds: 0, + }, + hardware_acquisition: "not-performed", + } as const; + const rawResult = { producer: "fixture", bytes: 12 }; + const profile = createAnalysisProfile(provider, { filter: null }); + const relationshipCoverage = { + status: "unknown", + reason: "Packet relationships are not reported.", + } as const; + const execution = createAnalysisExecution(report, provider, { + subject, + rawResult, + analysisProfile: profile, + relationshipCoverage, + locations: [{ kind: "artifact-path", path: input.path }], + }); + const controller = new AbortController(); + const reason = Object.freeze({ requested: "cancel after capture result" }); + const service = new PacketCaptureService({ + inspect: async (_input, options?: ExecutionOptions) => { + controller.abort(reason); + expect(options?.signal).toBe(controller.signal); + return ok(execution); + }, + inspectFields: async () => { + throw new Error("Unexpected field discovery call"); + }, + close: async () => {}, + }); + + const result = await service.inspect(input, { signal: controller.signal }); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected late cancellation"); + expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toMatchObject({ + operation: "inspect_packet_capture", + raw_result: rawResult, + analysis_profile: profile, + relationship_coverage: relationshipCoverage, + }); +}); diff --git a/src/application/captures/PacketCaptureService.ts b/src/application/captures/PacketCaptureService.ts index b6435b1a7c..3b91685ba8 100644 --- a/src/application/captures/PacketCaptureService.ts +++ b/src/application/captures/PacketCaptureService.ts @@ -3,24 +3,25 @@ import { packetFieldsCatalogSchema, type InspectPacketFieldsInput, } from "../../domain/captures/packetFields.js"; -import type { JsonValue } from "../../domain/jsonValue.js"; -import type { - AnalysisExecution, - ExecutionOptions, +import { + createAnalysisExecutionEvidence, + type AnalysisExecution, + type ExecutionOptions, } from "../AnalysisProvider.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; -import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import type { Evidence } from "../../domain/evidence.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { inspectPacketCaptureInputSchema, packetCaptureSchema, type InspectPacketCaptureInput, } from "../../domain/captures/packetCapture.js"; -import { err, ok, type Result } from "../../domain/result.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; export interface PacketCapturePort { /** Release per-call resources before returning, or retain failed cleanup for close/retry. */ @@ -74,7 +75,15 @@ export class PacketCaptureService { "Capture analyzer returned malformed data or changed the selected artifact identity/options.", ), ); - return this.#evidence(operation, input.data, execution, options); + return completeProviderEvidence( + createAnalysisExecutionEvidence( + operation, + input.data, + execution, + "observed", + ), + options?.signal, + ); } async inspectFields( raw: unknown, @@ -108,33 +117,14 @@ export class PacketCaptureService { "Field discovery returned malformed data or changed the selected installation/options.", ), ); - return this.#evidence(operation, input.data, execution, options); - } - #evidence( - operation: string, - parameters: Readonly>, - execution: AnalysisExecution, - options?: ExecutionOptions, - ): Result { - const evidence = createEvidence( - execution.subject ?? undefined, - execution.provider, - { + return completeProviderEvidence( + createAnalysisExecutionEvidence( operation, - parameters, - result: execution.result, - confidence: "observed", - locations: execution.locations, - limitations: execution.limitations, - }, + input.data, + execution, + "observed", + ), + options?.signal, ); - return options?.signal?.aborted - ? err( - new AnalysisCancelledError(operation, { - cause: options.signal.reason, - partialObservation: evidence, - }), - ) - : ok(evidence); } } diff --git a/src/application/firmware/FirmwareAnalysisService.test.ts b/src/application/firmware/FirmwareAnalysisService.test.ts index b060608d18..90be958f77 100644 --- a/src/application/firmware/FirmwareAnalysisService.test.ts +++ b/src/application/firmware/FirmwareAnalysisService.test.ts @@ -8,6 +8,7 @@ import { } from "../../domain/firmware/firmwareAnalysis.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { err, ok } from "../../domain/result.js"; +import { createAnalysisProfile } from "../../domain/analysisProfile.js"; const fixture = ( operation: FirmwareOperation, @@ -68,15 +69,35 @@ it.each(["inspect_firmware_regions", "extract_firmware"] as const)( "retains %s Evidence after late cancellation and preserves provider failure precedence", async (operation) => { const { input, execution } = fixture(operation); + const profile = createAnalysisProfile( + { ...execution.provider, version: "1" }, + { + signature_database: "caller-selected snapshot", + }, + ); + const coverage = { + status: "unknown", + reason: "No call graph examined", + } as const; + const observation = { + ...execution, + analysisProfile: profile, + relationshipCoverage: coverage, + }; const control = await new FirmwareAnalysisService({ - execute: async () => ok(execution), + execute: async () => ok(observation), }).execute(operation, input); if (!control.ok) throw control.error; + expect(control.value).toMatchObject({ + raw_result: { source: "controlled producer" }, + analysis_profile: profile, + relationship_coverage: coverage, + }); const controller = new AbortController(); const result = await new FirmwareAnalysisService({ execute: async () => { controller.abort(); - return ok(execution); + return ok(observation); }, }).execute(operation, input, { signal: controller.signal }); expect(result).toMatchObject({ diff --git a/src/application/firmware/FirmwareAnalysisService.ts b/src/application/firmware/FirmwareAnalysisService.ts index ba1b774d4b..223b14186a 100644 --- a/src/application/firmware/FirmwareAnalysisService.ts +++ b/src/application/firmware/FirmwareAnalysisService.ts @@ -1,4 +1,7 @@ -import type { ExecutionOptions } from "../AnalysisProvider.js"; +import { + createAnalysisExecutionEvidence, + type ExecutionOptions, +} from "../AnalysisProvider.js"; import type { FirmwareAnalysisPort } from "./FirmwareAnalysisPort.js"; import { firmwareRequestSchema, @@ -12,8 +15,7 @@ import { AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; -import { createEvidence, type Evidence } from "../../domain/evidence.js"; -import { jsonObjectSchema } from "../../domain/jsonValue.js"; +import type { Evidence } from "../../domain/evidence.js"; import { projectInputIssues } from "../../domain/inputIssueProjection.js"; import { err, type Result } from "../../domain/result.js"; import { completeProviderEvidence } from "../ProviderOperationExecution.js"; @@ -71,15 +73,12 @@ export class FirmwareAnalysisService { ), ); return completeProviderEvidence( - createEvidence(execution.subject, execution.provider, { + createAnalysisExecutionEvidence( operation, - parameters: jsonObjectSchema.parse(parsed.data.input), - result: execution.result, - rawResult: execution.rawResult, - locations: execution.locations, - limitations: execution.limitations, - confidence: "observed", - }), + parsed.data.input, + execution, + "observed", + ), options?.signal, ); } diff --git a/src/application/signals/SignalRecordingService.test.ts b/src/application/signals/SignalRecordingService.test.ts new file mode 100644 index 0000000000..57a891d3ab --- /dev/null +++ b/src/application/signals/SignalRecordingService.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "vitest"; + +import { + createAnalysisExecution, + type ExecutionOptions, +} from "../AnalysisProvider.js"; +import { SignalRecordingService } from "./SignalRecordingService.js"; +import { createAnalysisProfile } from "../../domain/analysisProfile.js"; +import { decodeSignalRecordingInputSchema } from "../../domain/signals/signalRecording.js"; +import { ok } from "../../domain/result.js"; + +it("retains provider metadata in a late-cancellation observation", async () => { + const input = decodeSignalRecordingInputSchema.parse({ + path: process.platform === "win32" ? "C:\\recording.vcd" : "/recording.vcd", + decoders: "uart", + }); + const provider = { + id: "fixture-signal", + name: "Fixture signal provider", + version: "1", + } as const; + const subject = { + path: input.path, + sha256: "a".repeat(64), + format: "file", + } as const; + const report = { + artifact: { path: input.path, sha256: subject.sha256, bytes: 12 }, + selection: input, + annotations_text: "10-20 uart-1: 42\n", + output_window: { total_lines: 1, returned_lines: 1, has_more: false }, + diagnostics: "", + tool: { + configured_path: "/fixture/sigrok-cli", + path: "/fixture/sigrok-cli", + sha256: "b".repeat(64), + version_banner: "sigrok-cli fixture", + version_diagnostics: "", + arguments: [], + }, + resource_limiter: { + configured_path: "/fixture/prlimit", + path: "/fixture/prlimit", + sha256: "c".repeat(64), + arguments: [], + }, + limits: { + input_bytes: 1, + output_bytes: 1, + command_timeout_ms: 1, + address_space_bytes: 0, + file_bytes: 0, + cpu_seconds: 0, + }, + hardware_acquisition: "not-performed", + } as const; + const rawResult = { producer: "fixture", bytes: 12 }; + const profile = createAnalysisProfile(provider, { decoder: "uart" }); + const relationshipCoverage = { + status: "unknown", + reason: "Recording relationships are not reported.", + } as const; + const execution = createAnalysisExecution(report, provider, { + subject, + rawResult, + analysisProfile: profile, + relationshipCoverage, + locations: [{ kind: "artifact-path", path: input.path }], + }); + const controller = new AbortController(); + const reason = Object.freeze({ requested: "cancel after recording result" }); + const service = new SignalRecordingService({ + decode: async (_input, options?: ExecutionOptions) => { + controller.abort(reason); + expect(options?.signal).toBe(controller.signal); + return ok(execution); + }, + inspectDecoders: async () => { + throw new Error("Unexpected decoder discovery call"); + }, + close: async () => {}, + }); + + const result = await service.decode(input, { signal: controller.signal }); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected late cancellation"); + expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toMatchObject({ + operation: "decode_signal_recording", + raw_result: rawResult, + analysis_profile: profile, + relationship_coverage: relationshipCoverage, + }); +}); diff --git a/src/application/signals/SignalRecordingService.ts b/src/application/signals/SignalRecordingService.ts index 3b433c26e2..38fd87e8ac 100644 --- a/src/application/signals/SignalRecordingService.ts +++ b/src/application/signals/SignalRecordingService.ts @@ -3,24 +3,25 @@ import { signalDecodersSchema, type InspectSignalDecodersInput, } from "../../domain/signals/signalDecoders.js"; -import type { JsonValue } from "../../domain/jsonValue.js"; -import type { - AnalysisExecution, - ExecutionOptions, +import { + createAnalysisExecutionEvidence, + type AnalysisExecution, + type ExecutionOptions, } from "../AnalysisProvider.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; -import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import type { Evidence } from "../../domain/evidence.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; import { decodeSignalRecordingInputSchema, signalRecordingSchema, type DecodeSignalRecordingInput, } from "../../domain/signals/signalRecording.js"; -import { err, ok, type Result } from "../../domain/result.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; export interface SignalRecordingPort { /** Release per-call resources before returning, or retain failed cleanup for close/retry. */ @@ -74,7 +75,15 @@ export class SignalRecordingService { "Decoder returned malformed data or changed the selected recording identity/options.", ), ); - return this.#evidence(operation, input.data, execution, options); + return completeProviderEvidence( + createAnalysisExecutionEvidence( + operation, + input.data, + execution, + "observed", + ), + options?.signal, + ); } async inspectDecoders( raw: unknown, @@ -108,33 +117,14 @@ export class SignalRecordingService { "Decoder discovery returned malformed data or changed the selected installation/options.", ), ); - return this.#evidence(operation, input.data, execution, options); - } - #evidence( - operation: string, - parameters: Readonly>, - execution: AnalysisExecution, - options?: ExecutionOptions, - ): Result { - const evidence = createEvidence( - execution.subject ?? undefined, - execution.provider, - { + return completeProviderEvidence( + createAnalysisExecutionEvidence( operation, - parameters, - result: execution.result, - confidence: "observed", - locations: execution.locations, - limitations: execution.limitations, - }, + input.data, + execution, + "observed", + ), + options?.signal, ); - return options?.signal?.aborted - ? err( - new AnalysisCancelledError(operation, { - cause: options.signal.reason, - partialObservation: evidence, - }), - ) - : ok(evidence); } } diff --git a/src/application/wasm/WasmArtifactService.test.ts b/src/application/wasm/WasmArtifactService.test.ts index e79b07b4a5..c4c9531e5a 100644 --- a/src/application/wasm/WasmArtifactService.test.ts +++ b/src/application/wasm/WasmArtifactService.test.ts @@ -5,6 +5,7 @@ import { wasmArtifactExecution, } from "../../../tests/fixtures/wasm/artifact.js"; import { ok } from "../../domain/result.js"; +import { createAnalysisProfile } from "../../domain/analysisProfile.js"; it.each([ { path: "relative" }, { path: "/module", glue_paths: ["relative"] }, @@ -119,30 +120,54 @@ it("cancels before provider work and delivers observed Evidence on success", asy }); }); -it("retains completed Evidence when cancellation arrives after provider return", async () => { - const value = wasmArtifactFixture(); - const controller = new AbortController(); - const service = new WasmArtifactService({ - close: () => Promise.resolve(), - inspect: async () => { - controller.abort(); - return ok(wasmArtifactExecution(value)); - }, - }); +it.each([false, true])( + "retains provider provenance after completion (late cancellation: %s)", + async (cancel) => { + const value = wasmArtifactFixture(); + const execution = wasmArtifactExecution(value); + const profile = createAnalysisProfile( + { ...execution.provider, version: "1" }, + { + features: "selected", + }, + ); + const coverage = { + status: "unknown", + reason: "No call graph examined", + } as const; + const controller = new AbortController(); + const service = new WasmArtifactService({ + close: () => Promise.resolve(), + inspect: async () => { + if (cancel) controller.abort(); + return ok({ + ...execution, + rawResult: { stdout: "retained command output" }, + analysisProfile: profile, + relationshipCoverage: coverage, + }); + }, + }); - expect( - await service.inspect( + const result = await service.inspect( { path: value.artifact.path }, { signal: controller.signal }, - ), - ).toMatchObject({ - ok: false, - error: { - _tag: "AnalysisCancelledError", - partialObservation: { normalized_result: value }, - }, - }); -}); + ); + if (cancel) + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + else expect(result.ok).toBe(true); + const evidence = result.ok ? result.value : result.error.partialObservation; + expect(evidence).toMatchObject({ + normalized_result: value, + raw_result: { stdout: "retained command output" }, + analysis_profile: profile, + relationship_coverage: coverage, + }); + }, +); it("preserves malformed-output failure when cancellation arrives with it", async () => { const value = wasmArtifactFixture(); diff --git a/src/application/wasm/WasmArtifactService.ts b/src/application/wasm/WasmArtifactService.ts index a77223d1f6..09cd529da1 100644 --- a/src/application/wasm/WasmArtifactService.ts +++ b/src/application/wasm/WasmArtifactService.ts @@ -1,11 +1,14 @@ -import type { ExecutionOptions } from "../AnalysisProvider.js"; +import { + createAnalysisExecutionEvidence, + type ExecutionOptions, +} from "../AnalysisProvider.js"; import type { WasmArtifactPort } from "./WasmArtifactPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; -import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import type { Evidence } from "../../domain/evidence.js"; import { inspectWasmArtifactInputSchema, wasmArtifactSchema, @@ -69,17 +72,12 @@ export class WasmArtifactService { ), ); return completeProviderEvidence( - createEvidence(execution.subject, execution.provider, { - operation: OPERATION, - parameters: input.data, - result: report.data, - ...(execution.rawResult === null - ? {} - : { rawResult: execution.rawResult }), - confidence: "observed", - locations: execution.locations, - limitations: execution.limitations, - }), + createAnalysisExecutionEvidence( + OPERATION, + input.data, + { ...execution, result: report.data }, + "observed", + ), options?.signal, ); }