diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index c00818fd2d..e1e58275bc 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -465,7 +465,9 @@ api-surface grouping, then keeps the groups on the primary origin, the origin with the most JSON-like records (`application/json`, `application/*+json`, `text/plain`, `text/markdown`), whose path starts with `/api/` or `/apis/` or whose content types are JSON-like, and the write groups answered with no body -(a bodiless `204 DELETE`). Every other group is listed under +(a bodiless `204 DELETE`). Empty retained response text establishes a bodiless +write only when its encoding is understood and unredacted; an opaque or hidden +encoding leaves the body unknown. Every other group is listed under `excluded` with reason `other-origin`, `ui-page-or-asset` or `non-json`, and retained records without a usable request line are counted in a limitation. diff --git a/src/domain/analysisView/EnvironmentManifestAccumulator.ts b/src/domain/analysisView/EnvironmentManifestAccumulator.ts index 8a9900bf29..abaaa9b879 100644 --- a/src/domain/analysisView/EnvironmentManifestAccumulator.ts +++ b/src/domain/analysisView/EnvironmentManifestAccumulator.ts @@ -11,6 +11,7 @@ import { type WebNetworkCaptureRecord, } from "../webNetworkCapture.js"; import { completeWithinViewCoverage } from "./analysisViewCoverage.js"; +import { recordedResponseBodyIsEmpty } from "./recordedResponseBody.js"; import type { UnsignedAnalysisView } from "./analysisView.js"; import { WebNetworkProjectionAccumulator } from "./WebNetworkProjectionAccumulator.js"; import { @@ -105,14 +106,14 @@ const responseTextSchema = z.object({ }), }); /** Whether a record's response had no body: a 204 or 205, an empty text, or a recorded size of zero; a missing text alone may be a body not kept. */ -const emptyResponse = (reported: unknown): boolean => { - const parsed = responseTextSchema.safeParse(reported); +const emptyResponse = (record: WebNetworkCaptureRecord): boolean => { + const parsed = responseTextSchema.safeParse(record.reported); if (!parsed.success) return false; const { status, content } = parsed.data.response; return ( status === 204 || status === 205 || - content?.text === "" || + recordedResponseBodyIsEmpty(record) || ((content?.text === undefined || content.text === null) && content?.size === 0) ); @@ -292,7 +293,7 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula route, queryNames, { key, group }, - { shape: bodyShape.value, empty: emptyResponse(record.reported) }, + { shape: bodyShape.value, empty: emptyResponse(record) }, ); } finally { this.releaseTemporary(); diff --git a/src/domain/analysisView/EnvironmentStateAccumulator.ts b/src/domain/analysisView/EnvironmentStateAccumulator.ts index 8c4a98cff1..4ca0f319b4 100644 --- a/src/domain/analysisView/EnvironmentStateAccumulator.ts +++ b/src/domain/analysisView/EnvironmentStateAccumulator.ts @@ -30,6 +30,7 @@ import { import { base64PrefixChars, readRecordedResponseBody, + recordedResponseBodyIsEmpty, type RecordedResponseBody, } from "./recordedResponseBody.js"; import { @@ -296,13 +297,7 @@ export class EnvironmentStateAccumulator extends EnvironmentManifestAccumulator const entry = this.#entries.get(ordinal); if (entry === undefined) return false; if (entry.facts.status === 204) return true; - // Emptiness needs only a tiny decoded prefix; never decode a whole body outside the state budget. - const body = readRecordedResponseBody(entry.record, 0); - return ( - body.bodyText === "" && - body.bodyEncoding === undefined && - body.bodyCut !== true - ); + return recordedResponseBodyIsEmpty(entry.record); } /** Project the init state of the manifest's primary origin; records keep timeline order. */ diff --git a/src/domain/analysisView/environmentManifestView.test.ts b/src/domain/analysisView/environmentManifestView.test.ts index 4c50886596..e6b4ae661b 100644 --- a/src/domain/analysisView/environmentManifestView.test.ts +++ b/src/domain/analysisView/environmentManifestView.test.ts @@ -1102,6 +1102,54 @@ it("keeps a write answered with no body as a tool whatever its content type, but ]); }); +it("keeps opaque or redacted empty response text unknown when selecting bodiless writes", () => { + const capture = syntheticCapture([ + ["POST", "https://s.test/opaque/1", "text/html"], + ["POST", "https://s.test/hidden/1", "text/html"], + ["POST", "https://s.test/base64/1", "text/html"], + ]); + for (const [index, record] of capture.records.entries()) { + record.reported = { + ...(record.reported as Record), + response: { + status: 200, + content: { + mimeType: "text/html", + text: "", + ...(index === 0 ? { encoding: "opaque" } : {}), + ...(index === 2 ? { encoding: "base64" } : {}), + }, + }, + }; + if (index === 1) + record.redactions = [ + { + pointer: "/response/content", + reason: "explicit-sensitive-value", + scope: "property-name", + }, + ]; + if (index === 2) + record.binary_fields = [ + { + pointer: "/response/content/text", + representation: "har-base64-content", + state: "retained", + content_base64: "", + bytes: 0, + sha256: + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + ]; + } + const projected = manifest([], capture); + expect(projected.tools.map(line)).toEqual(["POST /base64/{numeric}"]); + expect(projected.excluded.map((entry) => entry.path_template)).toEqual([ + "/hidden/{numeric}", + "/opaque/{numeric}", + ]); +}); + it("refuses a parent whose subject is absent or names another path or digest", () => { const parent = parentOf(); const refusal = { diff --git a/src/domain/analysisView/recordedResponseBody.ts b/src/domain/analysisView/recordedResponseBody.ts index ad781ec4ca..07286d1176 100644 --- a/src/domain/analysisView/recordedResponseBody.ts +++ b/src/domain/analysisView/recordedResponseBody.ts @@ -70,3 +70,15 @@ export const readRecordedResponseBody = ( return { bodyText: encoded, bodyEncoding: "base64", ...flags }; } }; + +/** Prove decoded emptiness with a bounded prefix, preserving encoding and cut uncertainty. */ +export const recordedResponseBodyIsEmpty = ( + record: WebNetworkCaptureRecord, +): boolean => { + const body = readRecordedResponseBody(record, 0); + return ( + body.bodyText === "" && + body.bodyEncoding === undefined && + body.bodyCut !== true + ); +};