fix(evidence): preserve provider execution provenance across workflows
This commit is contained in:
1 parent
87c8086fc1
commit
719d56ebfe
8 files changed
+334
-113
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, JsonValue>>,
|
||||
execution: AnalysisExecution,
|
||||
options?: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, JsonValue>>,
|
||||
execution: AnalysisExecution,
|
||||
options?: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user