diff --git a/src/browser/history/CaptureFailures.ts b/src/browser/history/CaptureFailures.ts index da429164c4..0b51e4780d 100644 --- a/src/browser/history/CaptureFailures.ts +++ b/src/browser/history/CaptureFailures.ts @@ -65,8 +65,8 @@ export const historicalCaptureFailure = ( origin === "owned-or-classified" && cause instanceof OwnedCommandFailure ) { - if (cause.cleanupFailure !== null) - return new ProviderCleanupError( + if (cause.cleanupFailure !== null) { + const cleanup = new ProviderCleanupError( input.format, cause.resources, { @@ -83,6 +83,14 @@ export const historicalCaptureFailure = ( }, { operation: OPERATION, cause }, ); + return cause.cause instanceof OwnedCommandFailure + ? analysisErrorWithCleanupFailure( + historicalCaptureFailure(input, cause.cause, phase, options, heap), + cleanup, + OPERATION, + ) + : cleanup; + } if (cause.reason === "cancelled") return new AnalysisCancelledError(OPERATION, { cause: diff --git a/src/browser/history/HistoricalCaptureDecoder.ts b/src/browser/history/HistoricalCaptureDecoder.ts index 37aaa5c524..89057e7c80 100644 --- a/src/browser/history/HistoricalCaptureDecoder.ts +++ b/src/browser/history/HistoricalCaptureDecoder.ts @@ -1,3 +1,4 @@ +import type { FileSnapshotPartialObservation } from "../../domain/fileSnapshotObservation.js"; import { decodeCaptureProtobuf, prepareCaptureProtobuf, @@ -30,7 +31,10 @@ import { ArtifactResourceScope, type ArtifactCleanupAttempt, } from "../../artifacts/ArtifactResourceScope.js"; -import { AnalysisCleanupLeaseGroup } from "../../artifacts/AnalysisCleanupLease.js"; +import { + AnalysisCleanupLease, + AnalysisCleanupLeaseGroup, +} from "../../artifacts/AnalysisCleanupLease.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCapabilityUnavailableError, @@ -50,7 +54,10 @@ import { type WebNetworkCapture, } from "../../domain/webNetworkCapture.js"; import { snapshotEnvironment } from "../../process/snapshotEnvironment.js"; -import { runOwnedCommand } from "../../process/OwnedCommand.js"; +import { + OwnedCommandFailure, + runOwnedCommand, +} from "../../process/OwnedCommand.js"; import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js"; import { redactExplicitFailure } from "../../domain/explicitSensitiveFailure.js"; @@ -124,6 +131,9 @@ export class HistoricalCaptureDecoder { ): Promise> { const selectedEnvironment = snapshotEnvironment(this.environment); let runtime: Pick | undefined; + const runtimeResources = new ArtifactResourceScope(); + let processLease: AnalysisCleanupLease | undefined; + let sourceObservation: FileSnapshotPartialObservation | undefined; let result: Result; let phase: "capture-read" | "decoder" = "capture-read"; let encodedReplyBytes = 0; @@ -149,6 +159,12 @@ export class HistoricalCaptureDecoder { undefined, this.#resources, ); + sourceObservation = { + kind: "file-snapshot-hash", + path: input.capture_path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }; phase = "decoder"; runtime = await this.createRuntime(); const snapshotPath = join(runtime.path, "capture.snapshot"); @@ -201,7 +217,7 @@ export class HistoricalCaptureDecoder { const decodedReply = await readHistoricalCaptureReply( replyPath, input.capture_path, - this.#resources, + runtimeResources, options?.signal, ); const reply = decodedReply.value; @@ -375,6 +391,29 @@ export class HistoricalCaptureDecoder { } } } catch (cause: unknown) { + if ( + cause instanceof OwnedCommandFailure && + cause.cleanupOwner !== undefined + ) { + const supervisor = cause.cleanupOwner; + processLease = new AnalysisCleanupLease(async () => { + const stopped = await supervisor.stop(); + if (stopped.status === "incomplete") + throw new ProviderCleanupError( + input.format, + cause.resources, + { + reason: stopped.reason, + }, + { operation: OPERATION, cause: stopped }, + ); + }, cause.resources); + runtimeResources.retain({ + kind: "lease", + resource: runtime?.path ?? input.capture_path, + lease: processLease, + }); + } const error = historicalCaptureFailure( input, cause, @@ -401,6 +440,8 @@ export class HistoricalCaptureDecoder { error, replyFinalization ?? cause, ); + if (processLease !== undefined) + new AnalysisCleanupLeaseGroup(failure, [processLease]); if (phase === "capture-read") { const snapshot = completedStableArtifactObservation( cause, @@ -408,6 +449,11 @@ export class HistoricalCaptureDecoder { ); if (snapshot !== undefined) failure.retainPartialObservation(snapshot); } + if ( + failure.partialObservation === undefined && + sourceObservation !== undefined + ) + failure.retainPartialObservation(sourceObservation); result = err(redactExplicitFailure(failure, input.sensitive_values)); } const completedCapture = result.ok ? result.value : undefined; @@ -415,12 +461,42 @@ export class HistoricalCaptureDecoder { | Extract | undefined; if (runtime !== undefined) { - const cleanup = await this.#resources.release({ - kind: "private-runtime-root", - resource: runtime.path, - root: runtime, - }); - if (cleanup.kind === "failed") cleanupFailure = cleanup; + const root = runtime; + const lease = new AnalysisCleanupLease( + async () => { + await runtimeResources.close(); + await root.close(); + }, + { + get cleanupResources() { + return [ + ...new Set([root.path, ...runtimeResources.cleanupResources]), + ]; + }, + }, + ); + const owner = { kind: "lease" as const, resource: root.path, lease }; + if (runtimeResources.hasPending) { + // Failed child cleanup is retried by the service owner, after active peers settle. + this.#resources.retain(owner); + cleanupFailure = { + kind: "failed", + owner, + lease, + cause: new ProviderCleanupError( + input.format, + lease.cleanupResources ?? [root.path], + { + reason: + "Capture decoder resources must be released before runtime removal.", + }, + { operation: OPERATION }, + ), + }; + } else { + const cleanup = await this.#resources.release(owner); + if (cleanup.kind === "failed") cleanupFailure = cleanup; + } } if (result.ok && options?.signal?.aborted) result = err( @@ -454,11 +530,10 @@ export class HistoricalCaptureDecoder { if (completedCapture !== undefined) failure.retainPartialObservation(completedCapture); new AnalysisCleanupLeaseGroup(cleanupError, [cleanupFailure.lease]); - const redacted = redactExplicitFailure(failure, input.sensitive_values); if (!result.ok) - redacted.bindCleanupDependencies([result.error, cleanupError]); - new AnalysisCleanupLeaseGroup(redacted, [cleanupFailure.lease]); - return err(redacted); + failure.bindCleanupDependencies([result.error, cleanupError]); + new AnalysisCleanupLeaseGroup(failure, [cleanupFailure.lease]); + return err(redactExplicitFailure(failure, input.sensitive_values)); } if (!result.ok && completedCapture !== undefined) result.error.retainPartialObservation(completedCapture); diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts index e779833436..0681d942cd 100644 --- a/src/cli/analysisEvidenceDelivery.ts +++ b/src/cli/analysisEvidenceDelivery.ts @@ -48,6 +48,8 @@ interface CliEvidenceDelivery { readonly evidenceOutput: string | undefined; /** Derived workflows publish their selected source closure with the result. */ readonly sourceEvidence?: readonly Evidence[]; + /** Invocation-specific exclusions also cover errors from final service cleanup. */ + readonly redactFailure?: (error: AnalysisError) => AnalysisError; } export const cliEvidenceDeliveryInputError = ( @@ -136,6 +138,10 @@ export const deliverCliAnalysisEvidence = async ( providerId, operation, signal, + undefined, + input.redactFailure === undefined + ? {} + : { redactFailure: input.redactFailure }, ); const complete = await finishAnalysisCommand( run, @@ -143,6 +149,10 @@ export const deliverCliAnalysisEvidence = async ( providerId, operation, signal, + undefined, + input.redactFailure === undefined + ? {} + : { redactFailure: input.redactFailure }, ); return publishCliAnalysisEvidence(complete, { providerId, diff --git a/src/cli/finishAnalysisCommand.ts b/src/cli/finishAnalysisCommand.ts index c05979eacb..5c8882907c 100644 --- a/src/cli/finishAnalysisCommand.ts +++ b/src/cli/finishAnalysisCommand.ts @@ -21,8 +21,14 @@ export const finishAnalysisCommand = async < canonicalOperation: string, signal: AbortSignal, fallbackResource?: string, - options: { readonly failureProjection?: "initial" | "final" } = {}, + options: { + readonly failureProjection?: "initial" | "final"; + /** Apply invocation-specific exclusions after all owned cleanup is composed. */ + readonly redactFailure?: (error: AnalysisError) => AnalysisError; + } = {}, ): Promise> => { + const projectFailure = (error: AnalysisError) => + projectAnalysisError(options.redactFailure?.(error) ?? error); let result: Result; let cleanup: AnalysisError | undefined; try { @@ -40,7 +46,7 @@ export const finishAnalysisCommand = async < // close retries and mutates the producer's live cleanup status. const initialFailure = !result.ok && options.failureProjection === "initial" - ? projectAnalysisError(result.error) + ? projectFailure(result.error) : undefined; try { // Resource owners may resolve their retained errors during close. This @@ -90,5 +96,5 @@ export const finishAnalysisCommand = async < ).bindCleanupDependencies([result.error, cleanup]), }; } - return result.ok ? result.value : projectAnalysisError(result.error); + return result.ok ? result.value : projectFailure(result.error); }; diff --git a/src/cli/webNetworkCaptureCommands.ts b/src/cli/webNetworkCaptureCommands.ts index 9f3ee1dc01..203355dfcb 100644 --- a/src/cli/webNetworkCaptureCommands.ts +++ b/src/cli/webNetworkCaptureCommands.ts @@ -17,6 +17,7 @@ import { import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { explicitSensitiveValueOptions } from "./explicitSensitiveArguments.js"; +import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js"; /** Expose offline historical capture inspection with the same selection contract as MCP. */ export const registerWebNetworkCaptureCommands = ( @@ -116,6 +117,8 @@ export const registerWebNetworkCaptureCommands = ( signal, detail: options.detail, evidenceOutput: options.evidence_output, + redactFailure: (error) => + redactExplicitFailure(error, options.sensitiveValue ?? []), }); }, ), diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index df7a5626df..22ce66121f 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -14,6 +14,39 @@ import { redactExplicitText } from "./explicitSensitiveValues.js"; import type { JsonValue } from "./jsonValue.js"; import { ProviderAdapterError } from "./providerAdapterError.js"; import { ProviderCleanupError } from "./providerCleanupError.js"; +import { + fileSnapshotPartialObservationSchema, + type FileSnapshotPartialObservation, +} from "./fileSnapshotObservation.js"; + +/** Exclude whole identity values; redacted text is no longer a path or digest. */ +const excludeSnapshotIdentity = ( + value: unknown, + values: readonly string[], +): FileSnapshotPartialObservation | undefined => { + if ( + value === null || + typeof value !== "object" || + !("kind" in value) || + (value.kind !== "file-snapshot-hash" && value.kind !== "file-snapshot-read") + ) + return undefined; + const parsed = fileSnapshotPartialObservationSchema.safeParse(value); + if (!parsed.success) return undefined; + const snapshot = parsed.data; + const exclude = (identity: string | null): string | null => + identity !== null && + values.some((literal) => literal !== "" && identity.includes(literal)) + ? null + : identity; + const path = exclude(snapshot.path); + if (snapshot.kind === "file-snapshot-hash") { + const sha256 = exclude(snapshot.sha256); + return { ...snapshot, path, sha256 }; + } + const admitted_sha256 = exclude(snapshot.admitted_sha256); + return { ...snapshot, path, admitted_sha256 }; +}; /** Keep a real JSON-pointer ancestor when a property identity is explicitly excluded. */ export const redactExplicitPointer = ( @@ -41,9 +74,11 @@ export const redactExplicitFailure = ( values: readonly string[], ): AnalysisError => { if (values.length === 0) return error; + const snapshot = excludeSnapshotIdentity(error.partialObservation, values); + if (snapshot !== undefined) error.partialObservation = snapshot; const redacted = redactFailure(error, values); - // Observation owners already apply format-aware exclusions. Keep their facts - // and identity across every diagnostic replacement, including input/capacity. + // Other observation owners already apply their format-aware exclusions. + // Keep those facts across diagnostic replacements, including input/capacity. if (redacted !== error && error.partialObservation !== undefined) redacted.retainPartialObservation(error.partialObservation); return redacted === error @@ -86,6 +121,8 @@ const redactFailure = ( .map(([key, item]) => [key, visit(item)]), ); const visit = (value: JsonValue): JsonValue => { + const snapshot = excludeSnapshotIdentity(value, values); + if (snapshot !== undefined) return snapshot; if (typeof value === "string") return text(value); if (Array.isArray(value)) return value.map(visit); if (typeof value === "object" && value !== null) return diagnostics(value); diff --git a/src/domain/fileSnapshotObservation.ts b/src/domain/fileSnapshotObservation.ts index a0a9340a16..3df6fac533 100644 --- a/src/domain/fileSnapshotObservation.ts +++ b/src/domain/fileSnapshotObservation.ts @@ -4,19 +4,21 @@ const byteCount = z.number().int().nonnegative().safe(); const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); /** Completed immutable-file facts retained when read finalization fails. */ +// Null identity fields mean caller-declared exclusion, never an absent file or +// sensitivity inferred from its name. Unmarked identities remain exact. export const fileSnapshotPartialObservationSchema = z.discriminatedUnion( "kind", [ z.strictObject({ kind: z.literal("file-snapshot-hash"), - path: z.string().min(1), - sha256, + path: z.string().min(1).nullable(), + sha256: sha256.nullable(), bytes: byteCount, }), z.strictObject({ kind: z.literal("file-snapshot-read"), - path: z.string().min(1), - admitted_sha256: sha256, + path: z.string().min(1).nullable(), + admitted_sha256: sha256.nullable(), capture: z.discriminatedUnion("status", [ z.strictObject({ status: z.literal("captured"), bytes: byteCount }), z.strictObject({ diff --git a/tests/boundary/browser/historicalCaptureDecoder.test.ts b/tests/boundary/browser/historicalCaptureDecoder.test.ts index e24a936757..5f0a7e8452 100644 --- a/tests/boundary/browser/historicalCaptureDecoder.test.ts +++ b/tests/boundary/browser/historicalCaptureDecoder.test.ts @@ -1,3 +1,6 @@ +import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { ProviderProcessSupervisor } from "../../../src/process/ProviderProcess.js"; +import { createDeferred } from "../../fixtures/binarySession.js"; import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; import { access, @@ -219,6 +222,177 @@ fs.writeFileSync(request.reply_path, ${JSON.stringify(JSON.stringify({ ok: true, }, ); +const expectOwnedDecoderPrimary = ( + error: AnalysisError, + outcome: "cancelled" | "process", + reason: unknown, +) => { + const primary = primaryAnalysisError(error); + if (outcome === "cancelled") { + expect(primary._tag).toBe("AnalysisCancelledError"); + expect(primary.cause).toBe(reason); + } else { + expect(primary.diagnostics).toMatchObject({ + exit_code: 7, + stderr: "authored workload failure", + }); + } +}; + +it.each(["cancelled", "process"] as const)( + "retains an unresolved decoder and its root across concurrent inspection and shutdown (%s)", + async (outcome) => { + const root = await mkdtemp(join(tmpdir(), "rea-capture-owned-cleanup-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const capturePath = join(root, "capture.har"); + const markerPath = join(root, "started.json"); + const finishPath = join(root, "finish"); + const text = JSON.stringify(historicalHar()); + await writeFile(capturePath, text); + const peerEntered = createDeferred(); + const peerContinue = createDeferred(); + const controller = new AbortController(); + const reason = { request: "cancel owned inert decoder" }; + let targetPid: number | undefined; + let targetOwner: ProviderProcessSupervisor | undefined; + let denied = true; + let targetStops = 0; + const stop = ProviderProcessSupervisor.prototype.stop; + vi.spyOn(ProviderProcessSupervisor.prototype, "stop").mockImplementation( + function (this: ProviderProcessSupervisor, options) { + if (this.launch.process.pid === targetPid) { + targetOwner = this; + targetStops++; + if (denied) + return Promise.resolve({ + status: "incomplete", + reason: "controlled stop refusal", + }); + } + return stop.call(this, options); + }, + ); + let calls = 0; + let roots = 0; + let targetRuntime: string | undefined; + let targetRootCloses = 0; + let denyRoot = false; + const decoder = new HistoricalCaptureDecoder( + [ + { + format: "har", + identity: HAR_CAPTURE_PROVIDER_IDENTITY, + command: async (_, runtimePath) => { + if (calls++ === 0) + return { + command: process.execPath, + arguments: [ + "-e", + `const fs = require("node:fs"); fs.writeFileSync(process.argv[1], JSON.stringify(process.pid)); setInterval(() => { if (fs.existsSync(process.argv[2])) { process.stderr.write("authored workload failure"); process.exit(7); } }, 10)`, + markerPath, + finishPath, + ], + }; + peerEntered.resolve(undefined); + await peerContinue.promise; + await writeFile( + join(runtimePath, "reply.json"), + JSON.stringify({ ok: true, value: decodeHarCapture(text, []) }), + ); + return { command: process.execPath, arguments: ["-e", ""] }; + }, + }, + ], + process.env, + async () => { + const runtime = await PrivateRuntimeRoot.create({ + prefix: "capture-private-runtime-", + }); + if (roots++ !== 0) return runtime; + targetRuntime = runtime.path; + return { + path: runtime.path, + close: async () => { + targetRootCloses++; + if (denyRoot) throw new Error("controlled root close refusal"); + await runtime.close(); + }, + }; + }, + ); + onTestFinished(async () => { + denied = false; + denyRoot = false; + peerContinue.resolve(undefined); + await targetOwner?.stop(); + await decoder.close(); + vi.restoreAllMocks(); + }); + const input = inspectWebNetworkCaptureInputSchema.parse({ + capture_path: capturePath, + format: "har", + sensitive_values: ["capture-private-runtime"], + }); + const target = decoder.inspect(input, { signal: controller.signal }); + await vi.waitFor( + async () => { + targetPid = JSON.parse(await readFile(markerPath, "utf8")); + }, + { timeout: 5000 }, + ); + const peer = decoder.inspect(input); + await peerEntered.promise; + if (outcome === "cancelled") controller.abort(reason); + else await writeFile(finishPath, "finish workload"); + const failed = await target; + if (failed.ok) throw new Error("cancelled decoder must fail"); + expect(targetRootCloses).toBe(0); + expectOwnedDecoderPrimary(failed.error, outcome, reason); + expect(failed.error.partialObservation).toMatchObject({ + kind: "file-snapshot-hash", + path: capturePath, + bytes: Buffer.byteLength(text), + sha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + expect(projectAnalysisError(failed.error).code).toBe("cleanup_incomplete"); + expect(JSON.stringify(projectAnalysisError(failed.error))).not.toContain( + "capture-private-runtime", + ); + if (targetRuntime === undefined) throw new Error("runtime missing"); + await expect(access(targetRuntime)).resolves.toBeUndefined(); + expect(targetOwner?.launch.process.exitCode).toBe( + outcome === "cancelled" ? null : 7, + ); + const closing = decoder.close().then( + () => undefined, + (cause) => cause, + ); + peerContinue.resolve(undefined); + expect((await peer).ok).toBe(true); + expect(await closing).toBeInstanceOf(Error); + expect(targetRootCloses).toBe(0); + denied = false; + denyRoot = true; + await expect(decoder.close()).rejects.toBeInstanceOf(Error); + expect(targetRootCloses).toBe(1); + const releasedStops = targetStops; + denyRoot = false; + await decoder.close(); + expect(targetStops).toBe(releasedStops); + await expect(access(targetRuntime)).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(projectAnalysisError(failed.error).code).not.toBe( + "cleanup_incomplete", + ); + expectOwnedDecoderPrimary(failed.error, outcome, reason); + expect(projectAnalysisError(failed.error).code).toBe( + outcome === "cancelled" ? "cancelled" : "execution_failure", + ); + }, + 15000, +); + it("applies a non-default capture deadline and releases its runtime", async () => { const root = await mkdtemp(join(tmpdir(), "rea-capture-deadline-")); onTestFinished(() => rm(root, { recursive: true, force: true })); @@ -859,7 +1033,11 @@ it.each([ "no completed capture Evidence", ); expect(projected.remediation.action).not.toContain("rea doctor"); - expect(result.error.partialObservation).toBeUndefined(); + expect(result.error.partialObservation).toMatchObject({ + kind: "file-snapshot-hash", + path, + sha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }); } else if (tag === "ProviderAdapterError") { expect(projected).toMatchObject({ details: { @@ -1111,7 +1289,12 @@ it.each([ : "unreadable_output", ); if (reply !== "capture") - expect(result.error.partialObservation).toBeUndefined(); + expect(result.error.partialObservation).toMatchObject({ + kind: "file-snapshot-hash", + path, + bytes: Buffer.byteLength(text), + sha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }); else expect(result.error.partialObservation).toMatchObject({ artifact: { path }, diff --git a/tests/process-global/historicalCaptureSnapshotRedaction.test.ts b/tests/process-global/historicalCaptureSnapshotRedaction.test.ts new file mode 100644 index 0000000000..a94d6d0658 --- /dev/null +++ b/tests/process-global/historicalCaptureSnapshotRedaction.test.ts @@ -0,0 +1,263 @@ +import { createHash } from "node:crypto"; +import type { FileHandle } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; + +const fault = vi.hoisted(() => ({ + path: "", + blocked: true, + handles: [] as FileHandle[], +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const filesystem = await importOriginal(); + return { + ...filesystem, + open: async (...args: Parameters) => { + const handle = await filesystem.open(...args); + if (String(args[0]) === fault.path) { + fault.handles.push(handle); + const close = handle.close.bind(handle); + handle.close = async () => { + if (fault.blocked) + throw new Error( + `controlled selected capture close failure: ${fault.path}`, + ); + await close(); + }; + } + return handle; + }, + }; +}); + +import { HistoricalCaptureDecoder } from "../../src/browser/history/HistoricalCaptureDecoder.js"; +import { HAR_CAPTURE_PROVIDER_IDENTITY } from "../../src/browser/history/CaptureRelease.js"; +import { WebNetworkCaptureService } from "../../src/application/WebNetworkCaptureService.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { fileSnapshotPartialObservationSchema } from "../../src/domain/fileSnapshotObservation.js"; +import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { historicalHar } from "../fixtures/historicalHar.js"; +import { readStableArtifact } from "../../src/artifacts/readStableArtifact.js"; +import { ArtifactOperationError } from "../../src/domain/artifactOperationError.js"; +import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../../src/domain/analysisErrorCleanup.js"; +import { err } from "../../src/domain/result.js"; +import { deliverCliAnalysisEvidence } from "../../src/cli/analysisEvidenceDelivery.js"; +import { redactExplicitFailure } from "../../src/domain/explicitSensitiveFailure.js"; + +const services: WebNetworkCaptureService[] = []; +afterEach(async () => { + fault.blocked = false; + for (const service of services.splice(0)) await service.close(); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + fault.handles = []; + fault.path = ""; +}); + +it.each(["path", "sha256"] as const)( + "excludes an explicitly sensitive selected capture %s from public snapshot facts", + async (sensitiveField) => { + const root = await createTestTempDirectory( + "rea-capture-snapshot-redaction-", + ); + const path = join(root, "private-capture-marker.har"); + const bytes = Buffer.from(JSON.stringify(historicalHar())); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + await writeFile(path, bytes); + fault.path = path; + fault.blocked = true; + const command = vi.fn(async () => { + throw new Error("Selected acquisition failure must precede decoding"); + }); + const service = new WebNetworkCaptureService( + new HistoricalCaptureDecoder( + [{ format: "har", identity: HAR_CAPTURE_PROVIDER_IDENTITY, command }], + process.env, + ), + ); + services.push(service); + const literal = + sensitiveField === "path" ? "private-capture-marker" : sha256; + const result = await service.inspect({ + capture_path: path, + format: "har", + sensitive_values: [literal], + }); + if (result.ok) + throw new Error("Expected selected descriptor close failure"); + const projected = analysisErrorProjectionSchema.parse( + projectAnalysisError(result.error), + ); + expect(projected.code).toBe("cleanup_incomplete"); + expect(command).not.toHaveBeenCalled(); + expect(fault.handles).toHaveLength(1); + expect(fault.handles[0]!.fd).toBeGreaterThanOrEqual(0); + expect(JSON.stringify(projected)).not.toContain(literal); + const partial = fileSnapshotPartialObservationSchema.parse( + projected.details?.partial_observation, + ); + expect(partial).toMatchObject({ bytes: bytes.length }); + expect(partial).toMatchObject( + sensitiveField === "path" ? { sha256 } : { path }, + ); + fault.blocked = false; + await service.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain( + literal, + ); + }, +); + +it("redacts repeated selected-file close failures at final CLI delivery and retains retry ownership", async () => { + const root = await createTestTempDirectory("rea-capture-cli-redaction-"); + const path = join(root, "private-cli-capture.har"); + const bytes = Buffer.from(JSON.stringify(historicalHar())); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + await writeFile(path, bytes); + fault.path = path; + fault.blocked = true; + const decoder = new HistoricalCaptureDecoder( + [ + { + format: "har", + identity: HAR_CAPTURE_PROVIDER_IDENTITY, + command: async () => { + throw new Error("Acquisition must precede decoding"); + }, + }, + ], + process.env, + ); + const service = new WebNetworkCaptureService(decoder); + services.push(service); + const values = ["private-cli-capture"]; + const output = analysisErrorProjectionSchema.parse( + await deliverCliAnalysisEvidence({ + run: () => + service.inspect({ + capture_path: path, + format: "har", + sensitive_values: values, + }), + service, + providerId: "web-network-capture", + operation: "inspect_web_network_capture", + signal: new AbortController().signal, + detail: "complete", + evidenceOutput: undefined, + redactFailure: (error) => redactExplicitFailure(error, values), + }), + ); + expect(output.code).toBe("cleanup_incomplete"); + expect(output.details).toMatchObject({ + execution_failure: "execution_failure", + partial_observation: { + kind: "file-snapshot-hash", + path: null, + sha256, + bytes: bytes.length, + }, + diagnostics: { + primary_error: { code: "cleanup_incomplete" }, + cleanup_error: { code: "cleanup_incomplete" }, + }, + }); + expect(JSON.stringify(output)).not.toContain(values[0]!); + expect(fault.handles).toHaveLength(1); + expect(fault.handles[0]!.fd).toBeGreaterThanOrEqual(0); + await expect(service.close()).rejects.toThrow(); + fault.blocked = false; + await expect(service.close()).resolves.toBeUndefined(); + expect(fault.handles[0]!.fd).toBe(-1); +}); + +it.each(["captured", "over-capacity"] as const)( + "preserves %s read facts through unchanged-error and cleanup-primary service redaction", + async (status) => { + const root = await createTestTempDirectory("rea-read-snapshot-redaction-"); + const path = join(root, "private-read-marker.bin"); + await writeFile(path, "physical snapshot bytes"); + const snapshot = await readStableArtifact(path, 1024); + const capture = + status === "captured" + ? { status, bytes: snapshot.bytes.length } + : { + status, + source_bytes_at_least: snapshot.bytes.length, + maximum_bytes: 1, + }; + const partial = fileSnapshotPartialObservationSchema.parse({ + kind: "file-snapshot-read", + path, + admitted_sha256: snapshot.sha256, + capture, + }); + const primary = new ArtifactOperationError( + "inspect_artifact", + "io", + undefined, + undefined, + { partialObservation: partial }, + ); + const service = new WebNetworkCaptureService({ + inspect: async () => err(primary), + }); + const input = { + capture_path: path, + format: "har", + sensitive_values: ["private-read-marker", snapshot.sha256], + }; + const result = await service.inspect(input); + if (result.ok) throw new Error("Expected retained read failure"); + // This error type is intentionally returned unchanged by diagnostic redaction. + expect(result.error).toBe(primary); + const expected = { + kind: "file-snapshot-read", + path: null, + admitted_sha256: null, + capture, + }; + expect(primary.partialObservation).toEqual(expected); + const repeated = await service.inspect(input); + if (repeated.ok) throw new Error("Expected repeated retained read failure"); + expect(repeated.error.partialObservation).toEqual(expected); + + // Recreate the unsanitized source under cleanup to exercise recursive ownership. + const nested = new ArtifactOperationError( + "inspect_artifact", + "io", + undefined, + undefined, + { partialObservation: partial }, + ); + const cleanup = new ProviderCleanupError("fixture", [path], {}); + const composed = analysisErrorWithCleanupFailure(nested, cleanup); + composed.partialObservation = undefined; + const nestedService = new WebNetworkCaptureService({ + inspect: async () => err(composed), + }); + const nestedResult = await nestedService.inspect(input); + if (nestedResult.ok) + throw new Error("Expected nested retained read failure"); + const projected = analysisErrorProjectionSchema.parse( + projectAnalysisError(nestedResult.error), + ); + expect( + fileSnapshotPartialObservationSchema.parse( + nestedResult.error.cleanupPrimary?.partialObservation, + ), + ).toEqual(expected); + expect(projected.details?.diagnostics).toMatchObject({ + primary_error: { details: { partial_observation: expected } }, + }); + expect(JSON.stringify(projected)).not.toContain("private-read-marker"); + expect(JSON.stringify(projected)).not.toContain(snapshot.sha256); + composed.resolveCleanup(); + const finalized = projectAnalysisError(nestedResult.error); + expect(finalized.details?.partial_observation).toEqual(expected); + }, +);