Files
reverse-engineering-anything/tests/composition/webCaptureDeliveryRedaction.test.ts
T
JG Trompandmorluto 005435ca78 fix(cli): publish partial Evidence to --evidence_output on failure (#7797)
* fix(cli): publish partial Evidence to --evidence_output on failure

publishCliAnalysisEvidence returned a failed result unchanged before any
--evidence_output handling. When a source reached its deadline, or another
failure carried partial Evidence, the requested file was not written and
--detail summary still printed the complete partial Evidence inline, which
reached more than 64 MB on a large build.

A failure whose details.partial_observation is authenticated Evidence now
publishes it through the same projection and new-file publication as a
successful result. The failure keeps its code, message and remediation.
With --detail summary, details.partial_observation holds the summary
view, and details.diagnostics.evidence_bundle reports the path and bytes.
A refused publication keeps the complete partial Evidence inline and adds
details.diagnostics.evidence_bundle_error with the refusal and a
limitation. Without --evidence_output, for cancellation, and for raw
provider observations the failure output is unchanged.

Fixes #7787

* fix(cli): keep committed partial Evidence receipts on refused publication

When publishing the partial Evidence of a failed analysis, a refusal can
follow a commit. Staging cleanup that stays incomplete after its retry
returns cleanup_incomplete with published_path and bytes, and a
cancellation after commit returns cancelled with
diagnostics.evidence_bundle. The failure path read only the second form,
so a committed file with incomplete cleanup was reported as not
published, without its receipt or the cleanup resources, and the full
partial Evidence went back inline.

Keep the original analysis failure and record the receipt from either
form in details.diagnostics.evidence_bundle. Incomplete publication
cleanup adds published, published_path, bytes, cleanup and resources to
details, the shape a successful run's cleanup_incomplete reports;
resources join any the analysis failure already reports, and its own
cleanup reason is kept. Once the file is committed, the delivered view
(the summary with --detail summary) replaces the inline partial Evidence.
Receipts omit a path that holds an excluded sensitive value, as on
success.

Tests drive the real staged publication through its commit point: cleanup
that stays incomplete, cleanup confirmed by retry, and SIGINT after
commit. A composition test checks receipt redaction on the failure path.

* fix(cli): report incomplete partial publication cleanup

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-11-11 03:36:14 +08:00

242 lines
8.7 KiB
TypeScript

import { createHash } from "node:crypto";
import { writeFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { WebNetworkCaptureService } from "../../src/application/WebNetworkCaptureService.js";
import { decodeHarCapture } from "../../src/browser/history/HarCapture.js";
import {
deliverCliAnalysisEvidence,
publishCliAnalysisEvidence,
} from "../../src/cli/analysisEvidenceDelivery.js";
import { createEvidence } from "../../src/domain/evidence.js";
import { parseEvidenceBundle } from "../../src/domain/evidenceBundle.js";
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js";
import { AnalysisTimeoutError } from "../../src/domain/analysisErrorCore.js";
import { err, ok } from "../../src/domain/result.js";
import { historicalHar } from "../fixtures/historicalHar.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
it("excludes marked output identity from late CLI publication failure while retaining capture Evidence", async () => {
const root = await createTestTempDirectory("rea-capture-delivery-");
const outputPath = join(root, "private-publication-marker.json");
await writeFile(outputPath, "existing caller file");
const bytes = JSON.stringify(historicalHar());
const values = ["private-publication-marker"];
const input = {
capture_path: join(root, "capture.har"),
format: "har" as const,
sensitive_values: values,
};
const service = new WebNetworkCaptureService({
inspect: async () =>
ok({
...decodeHarCapture(bytes, values),
artifact: {
path: input.capture_path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: Buffer.byteLength(bytes),
},
format: "har" as const,
runtime_attribution: "unknown" as const,
limitations: [],
}),
});
const output = analysisErrorProjectionSchema.parse(
await deliverCliAnalysisEvidence({
run: () => service.inspect(input),
service,
providerId: "web-network-capture",
operation: "inspect_web_network_capture",
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: outputPath,
sensitiveValues: values,
}),
);
expect(output.code).toBe("invalid_request");
expect(output.details).toHaveProperty(
"partial_observation.normalized_result.artifact.path",
input.capture_path,
);
expect(JSON.stringify(output)).not.toContain(values[0]);
expect(await readFile(outputPath, "utf8")).toBe("existing caller file");
});
it.each([true, false])(
"publishes summary bytes while excluding only a marked receipt identity: %s",
async (marked) => {
const root = await createTestTempDirectory("rea-capture-publication-");
const outputPath = join(root, "private-publication-marker.json");
const values = marked ? ["private-publication-marker"] : [];
const bytes = JSON.stringify(historicalHar());
const input = {
capture_path: join(root, "capture.har"),
format: "har" as const,
sensitive_values: values,
};
const service = new WebNetworkCaptureService({
inspect: async () =>
ok({
...decodeHarCapture(bytes, values),
artifact: {
path: input.capture_path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: Buffer.byteLength(bytes),
},
format: "har" as const,
runtime_attribution: "unknown" as const,
limitations: [],
}),
});
const output = await deliverCliAnalysisEvidence({
run: () => service.inspect(input),
service,
providerId: "web-network-capture",
operation: "inspect_web_network_capture",
signal: new AbortController().signal,
detail: "summary",
evidenceOutput: outputPath,
sensitiveValues: values,
});
const persisted = await readFile(outputPath);
expect(output).toMatchObject({
evidence_bundle: { bytes: persisted.length },
});
if (marked) {
expect(output).not.toHaveProperty("evidence_bundle.path");
expect(JSON.stringify(output)).not.toContain(values[0]);
} else expect(output).toHaveProperty("evidence_bundle.path", outputPath);
expect(JSON.parse(persisted.toString())).toHaveProperty(
"records.0.operation",
"inspect_web_network_capture",
);
},
);
it.each([true, false])(
"publishes partial Evidence from a failure while excluding only a marked receipt identity: %s",
async (marked) => {
const root = await createTestTempDirectory("rea-capture-partial-");
const outputPath = join(root, "private-publication-marker.json");
const values = marked ? ["private-publication-marker"] : [];
const bytes = JSON.stringify(historicalHar());
const input = {
capture_path: join(root, "capture.har"),
format: "har" as const,
sensitive_values: values,
};
const service = new WebNetworkCaptureService({
inspect: async () =>
ok({
...decodeHarCapture(bytes, values),
artifact: {
path: input.capture_path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: Buffer.byteLength(bytes),
},
format: "har" as const,
runtime_attribution: "unknown" as const,
limitations: [],
}),
});
const completed = await service.inspect(input);
if (!completed.ok) throw completed.error;
const output = analysisErrorProjectionSchema.parse(
await deliverCliAnalysisEvidence({
run: async () =>
err(
new AnalysisTimeoutError("inspect_web_network_capture", 1, {
partialObservation: completed.value,
}),
),
service,
providerId: "web-network-capture",
operation: "inspect_web_network_capture",
signal: new AbortController().signal,
detail: "summary",
evidenceOutput: outputPath,
sensitiveValues: values,
}),
);
const persisted = await readFile(outputPath);
expect(output.code).toBe("provider_timeout");
expect(output.details).toHaveProperty(
"diagnostics.evidence_bundle.bytes",
persisted.length,
);
if (marked) {
expect(output.details).not.toHaveProperty(
"diagnostics.evidence_bundle.path",
);
expect(JSON.stringify(output)).not.toContain(values[0]);
} else
expect(output.details).toHaveProperty(
"diagnostics.evidence_bundle.path",
outputPath,
);
expect(JSON.parse(persisted.toString())).toHaveProperty(
"records.0.evidence_id",
completed.value.evidence_id,
);
},
);
it.each([true, false])(
"preserves separately captured sources during partial publication (refused: %s)",
async (refused) => {
const root = await createTestTempDirectory("rea-partial-captured-sources-");
const outputPath = join(root, "partial.json");
if (refused) await writeFile(outputPath, "existing caller file");
const provider = { id: "fixture", name: "Fixture", version: "1" };
const source = createEvidence(undefined, provider, {
operation: "source_fixture",
parameters: {},
result: { observed: 7 },
});
const partial = createEvidence(undefined, provider, {
operation: "derived_fixture",
parameters: {},
result: { completed_sources: 1 },
evidenceLinks: [source.evidence_id],
});
const failure = projectAnalysisError(
new AnalysisTimeoutError("derived_fixture", 1, {
partialObservation: partial,
}),
);
const output = analysisErrorProjectionSchema.parse(
await publishCliAnalysisEvidence(
{ ok: false, error: failure, sourceEvidence: [source] },
{
providerId: "fixture",
operation: "derived_fixture",
signal: new AbortController().signal,
detail: "complete",
evidenceOutput: outputPath,
},
),
);
expect(output.code).toBe("provider_timeout");
if (refused) {
expect(await readFile(outputPath, "utf8")).toBe("existing caller file");
expect(output.details).toMatchObject({
source_closure: {
publication: "not_published",
complete: true,
records: [source],
omitted_count: 0,
},
});
} else {
const bundle = parseEvidenceBundle(
JSON.parse(await readFile(outputPath, "utf8")),
);
expect(
new Set(bundle.records.map((record) => record.evidence_id)),
).toEqual(new Set([source.evidence_id, partial.evidence_id]));
expect(output.details).not.toHaveProperty("source_closure");
}
},
);