From d9daf09ea558094d0629d2a33420d0c9ec52dff9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:13:55 +0800 Subject: [PATCH] refactor(capture): report decoder failures through ParseFailure with named limits (#10708) --- .../verify/architecture-guard-baselines.json | 1 - src/browser/history/CaptureFailures.test.ts | 66 +++++++- src/browser/history/CaptureFailures.ts | 38 +++++ src/browser/history/CaptureFormatError.ts | 150 ++++++++++++++++-- src/browser/history/CaptureJson.ts | 22 +-- src/browser/history/HarCapture.test.ts | 16 +- src/browser/history/HarCapture.ts | 8 +- src/browser/history/HarSchema.ts | 2 +- .../history/HistoricalCaptureDecoder.ts | 89 +++-------- src/browser/history/NodeCaptureProcess.ts | 2 +- src/browser/history/QlogCapture.test.ts | 8 +- src/browser/history/QlogCapture.ts | 2 +- src/browser/history/SazCapture.test.ts | 2 +- src/browser/history/SazCapture.ts | 21 +-- src/browser/history/SazHttp.ts | 16 +- src/browser/history/SazMetadata.test.ts | 4 +- src/browser/history/SazMetadata.ts | 8 +- src/browser/history/SazProjection.ts | 2 +- .../history/playwrightTrace/TraceStream.ts | 27 ++-- .../history/protobuf/ProtobufCapture.ts | 2 +- .../history/protobuf/ProtobufFraming.ts | 2 +- .../history/protobuf/ProtobufWire.test.ts | 4 +- src/browser/history/protobuf/ProtobufWire.ts | 5 +- 23 files changed, 341 insertions(+), 156 deletions(-) diff --git a/scripts/verify/architecture-guard-baselines.json b/scripts/verify/architecture-guard-baselines.json index 84cb88d11a..4ae34e22a1 100644 --- a/scripts/verify/architecture-guard-baselines.json +++ b/scripts/verify/architecture-guard-baselines.json @@ -186,7 +186,6 @@ }, "parser-format-failure-class": { "src/browser/assets/WebArtifactReads.ts": 1, - "src/browser/history/CaptureFormatError.ts": 1, "src/domain/xmlDocument.ts": 1, "src/native/AppleMachoSelection.ts": 1, "src/native/MachoCommandNames.ts": 1, diff --git a/src/browser/history/CaptureFailures.test.ts b/src/browser/history/CaptureFailures.test.ts index bdd0fa7d66..cb157d1cce 100644 --- a/src/browser/history/CaptureFailures.test.ts +++ b/src/browser/history/CaptureFailures.test.ts @@ -7,7 +7,14 @@ import { import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { analysisErrorProjectionSchema } from "../../contracts/errorSchemas.js"; import { inspectWebNetworkCaptureInputSchema } from "../../domain/webNetworkCapture.js"; -import { historicalCaptureFailure } from "./CaptureFailures.js"; +import { + historicalCaptureFailure, + protobufCaptureFailure, +} from "./CaptureFailures.js"; +import { + CaptureFormatError, + captureLimitForReply, +} from "./CaptureFormatError.js"; import { NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "../../../tests/fixtures/captureDecoderLimits.js"; import { redactExplicitFailure } from "../../domain/explicitSensitiveFailure.js"; import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; @@ -405,3 +412,60 @@ it("keeps physical capture read errors as provider failures", () => { details: { diagnostics: { phase: "capture-read" } }, }); }); + +it.each([ + { + limit: { name: "protobuf-body", maximum: 1024 }, + parameter: "max_input_bytes", + expected: "maximum_protobuf_body_bytes", + }, + { + limit: { name: "protobuf-decoding", maximum: 2048 }, + parameter: "max_reply_bytes", + expected: "maximum_encoded_capture_bytes", + }, +] as const)( + "names the $parameter budget for a $limit.name refusal", + ({ limit, parameter, expected }) => { + const input = inspectWebNetworkCaptureInputSchema.parse({ + capture_path: "/selected/capture.har", + format: "har", + }); + const projected = projectAnalysisError( + protobufCaptureFailure( + input, + new CaptureFormatError(limit, "Protobuf limit reached"), + ), + ); + expect(projected).toMatchObject({ + code: "resource_constraint", + retryable: false, + details: { reported_limits: { [expected]: limit.maximum } }, + remediation: { + action: expect.stringContaining(`Increase ${parameter}`), + }, + }); + }, +); + +it("derives each decoder-child wire reason from the limit table", () => { + expect( + new CaptureFormatError( + { name: "nesting-depth", maximum: 64 }, + "deep", + "/log", + ), + ).toMatchObject({ replyReason: "input-limit", pointer: "/log" }); + expect( + new CaptureFormatError({ name: "expanded-input", maximum: 1 }, "big") + .replyReason, + ).toBe("expanded-limit"); + expect(new CaptureFormatError("malformed", "bad").replyReason).toBe("format"); + expect(new CaptureFormatError("unsupported", "no").replyReason).toBe( + "unsupported", + ); + expect(captureLimitForReply("input-limit")).toBe("nesting-depth"); + expect(captureLimitForReply("expanded-limit")).toBe("expanded-input"); + // A reply-size refusal is not a decoder input limit. + expect(captureLimitForReply("limit")).toBeUndefined(); +}); diff --git a/src/browser/history/CaptureFailures.ts b/src/browser/history/CaptureFailures.ts index 6e5f3060f6..e9a362bd76 100644 --- a/src/browser/history/CaptureFailures.ts +++ b/src/browser/history/CaptureFailures.ts @@ -39,8 +39,46 @@ import { import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; import { NODE_CAPTURE_HEAP_ENFORCED } from "./CaptureRelease.js"; import { CAPTURE_DECODER_SEMI_SPACE_MIB } from "../../domain/analysisResourceLimits.js"; +import { + CAPTURE_LIMITS, + type CaptureFormatError, +} from "./CaptureFormatError.js"; const OPERATION = "inspect_web_network_capture"; +/** A refused protobuf body decode; a limit names the budget that owns it. */ +export const protobufCaptureFailure = ( + input: InspectWebNetworkCaptureInput, + cause: CaptureFormatError, +): AnalysisError => { + const { limit } = cause; + if (limit === undefined) + return new AnalysisInputError(OPERATION, undefined, [ + { + path: ["protobuf"], + reason: "invalid_format", + message: cause.message, + }, + ]); + const { parameter, expected } = CAPTURE_LIMITS[limit.name]; + return new AnalysisResourceConstraintError( + OPERATION, + "capacity", + cause.message, + { [expected]: limit.maximum }, + { + retryable: false, + ...(parameter === null + ? {} + : { + remediationAction: webNetworkCaptureBudgetRecovery( + input, + parameter, + ), + }), + }, + ); +}; + /** Reason for a decoder whose owned process tree reached its Job Object commit limit. */ export const CAPTURE_PROCESS_MEMORY_LIMIT_REASON = "The capture decoder reached its Windows Job Object memory limit"; diff --git a/src/browser/history/CaptureFormatError.ts b/src/browser/history/CaptureFormatError.ts index 17ce16a86f..c63564e09e 100644 --- a/src/browser/history/CaptureFormatError.ts +++ b/src/browser/history/CaptureFormatError.ts @@ -1,25 +1,141 @@ -/** The fixed parser allowance an `input-limit` refusal exhausted. */ -export interface CaptureInputAllowance { - readonly kind: "parser-nodes" | "nesting-depth"; - readonly limit: number; - readonly unit: "parser nodes" | "nesting levels"; -} +import { ParseFailureError } from "../../domain/parseFailure.js"; + +/** The ParseFailure format of a historical web network capture. */ +export const CAPTURE_FORMAT = "web-network-capture"; + +/** + * Each named capture decode budget: the public control that raises it (null + * for a fixed allowance), the unit of its maximum, and the wire reason a + * decoder child reports for it. Protobuf bodies decode in the parent and have + * no wire reason; the mitmproxy bridge reports the same wire reasons. + */ +export const CAPTURE_LIMITS = { + "nesting-depth": { + parameter: "max_nesting_depth", + unit: "nesting levels", + reply: "input-limit", + expected: "maximum_capture_nesting", + }, + "parser-nodes": { + parameter: null, + unit: "parser nodes", + reply: null, + expected: "maximum_parser_nodes", + }, + "expanded-input": { + parameter: "max_input_bytes", + unit: "bytes", + reply: "expanded-limit", + expected: "maximum_expanded_capture_bytes", + }, + "protobuf-body": { + parameter: "max_input_bytes", + unit: "bytes", + reply: null, + expected: "maximum_protobuf_body_bytes", + }, + "protobuf-decoding": { + parameter: "max_reply_bytes", + unit: "bytes", + reply: null, + expected: "maximum_encoded_capture_bytes", + }, +} as const; +export type CaptureLimitName = keyof typeof CAPTURE_LIMITS; + +/** A limit a decoder child can report on the reply wire. */ +type CaptureReplyLimitName = { + [ + Name in CaptureLimitName + ]: (typeof CAPTURE_LIMITS)[Name]["reply"] extends null ? never : Name; +}[CaptureLimitName]; +const CAPTURE_REPLY_LIMITS = ( + Object.keys(CAPTURE_LIMITS) as CaptureLimitName[] +).filter( + (name): name is CaptureReplyLimitName => CAPTURE_LIMITS[name].reply !== null, +); + +/** The limit whose wire reason a decoder child reported, if any. */ +export const captureLimitForReply = ( + reason: string, +): CaptureReplyLimitName | undefined => + CAPTURE_REPLY_LIMITS.find((name) => CAPTURE_LIMITS[name].reply === reason); /** Expected malformed, unsupported or oversized producer data at a historical decoder boundary. */ -export class CaptureFormatError extends Error { +export class CaptureFormatError extends ParseFailureError< + "malformed" | "unsupported" | "limit" +> { constructor( - readonly reason: - | "format" - | "unsupported" - | "input-limit" - | "expanded-limit" - | "limit", + kind: "malformed" | "unsupported", message: string, - readonly pointer = "", + pointer?: string, + cause?: unknown, + ); + constructor( + limit: { readonly name: CaptureLimitName; readonly maximum: number }, + message: string, + pointer?: string, + ); + constructor( + kind: + | "malformed" + | "unsupported" + | { readonly name: CaptureLimitName; readonly maximum: number }, + message: string, + pointer = "", cause?: unknown, - /** Which allowance an `input-limit` exhausted, when the parser knows it. */ - readonly allowance?: CaptureInputAllowance, ) { - super(message, cause === undefined ? undefined : { cause }); + const field = pointer === "" ? {} : { field: pointer }; + super( + kind === "malformed" + ? { kind, format: CAPTURE_FORMAT, detail: message, ...field } + : kind === "unsupported" + ? { + kind, + format: CAPTURE_FORMAT, + detail: message, + undecodedContent: false, + ...field, + } + : { + kind: "limit", + format: CAPTURE_FORMAT, + detail: message, + limit: { name: kind.name, maximum: kind.maximum }, + ...field, + }, + cause === undefined ? undefined : { cause }, + ); + this.name = "CaptureFormatError"; + } + + /** JSON pointer of the failing capture value; empty for the whole input. */ + get pointer(): string { + return this.failure.field ?? ""; + } + + /** The exhausted budget, for a `limit` failure. */ + get limit(): + | { readonly name: CaptureLimitName; readonly maximum: number } + | undefined { + return this.failure.kind === "limit" + ? { + name: this.failure.limit.name as CaptureLimitName, + maximum: this.failure.limit.maximum, + } + : undefined; + } + + /** The reason a decoder child reports for this failure on the reply wire. */ + get replyReason(): + | "format" + | "unsupported" + | "decoder" + | "input-limit" + | "expanded-limit" { + const { limit } = this; + if (limit === undefined) + return this.failure.kind === "unsupported" ? "unsupported" : "format"; + return CAPTURE_LIMITS[limit.name].reply ?? "decoder"; } } diff --git a/src/browser/history/CaptureJson.ts b/src/browser/history/CaptureJson.ts index 8c097724e3..22c0ad46c2 100644 --- a/src/browser/history/CaptureJson.ts +++ b/src/browser/history/CaptureJson.ts @@ -21,11 +21,8 @@ const captureNodeAdmission = (maximumNodes: number | undefined) => { nodes++; if (maximumNodes !== undefined && nodes > maximumNodes) throw new CaptureFormatError( - "input-limit", + { name: "parser-nodes", maximum: maximumNodes }, "JSON exceeds its parser node representation allowance.", - "", - undefined, - { kind: "parser-nodes", limit: maximumNodes, unit: "parser nodes" }, ); }; }; @@ -40,7 +37,7 @@ const selectCaptureObjectProperty = ( throw new TypeError("JSON visitor emitted a property outside an object."); if (frame.members.has(key)) throw new CaptureFormatError( - "format", + "malformed", `${format} JSON contains duplicate object members.`, ); frame.pendingKey = key; @@ -71,15 +68,9 @@ export const parseCaptureJson = ( admitNode(); if (frames.length >= captureBudgets().depth) throw new CaptureFormatError( - "input-limit", + { name: "nesting-depth", maximum: captureBudgets().depth }, `${format} exceeds max_nesting_depth (${String(captureBudgets().depth)} levels).`, valuePointer(), - undefined, - { - kind: "nesting-depth", - limit: captureBudgets().depth, - unit: "nesting levels", - }, ); }; const append = (value: unknown): void => { @@ -138,7 +129,10 @@ export const parseCaptureJson = ( throw new TypeError("JSON visitor emitted an unsupported literal."); }, onError: () => { - throw new CaptureFormatError("format", `${format} is malformed JSON.`); + throw new CaptureFormatError( + "malformed", + `${format} is malformed JSON.`, + ); }, }, { @@ -159,7 +153,7 @@ export const decodeCaptureText = ( return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { throw new CaptureFormatError( - "format", + "malformed", `${format} is not valid UTF-8 JSON.`, ); } diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts index 38a0c06097..cec83ef027 100644 --- a/src/browser/history/HarCapture.test.ts +++ b/src/browser/history/HarCapture.test.ts @@ -34,7 +34,7 @@ it.each([ }); expect(() => decodeHarCapture(text, [])).toThrow( expect.objectContaining({ - reason: "format", + failure: expect.objectContaining({ kind: "malformed" }), pointer: `/log/entries/0/timings${field}`, }), ); @@ -67,7 +67,7 @@ it.each([ ), ).toThrow( expect.objectContaining({ - reason: "format", + failure: expect.objectContaining({ kind: "malformed" }), pointer: "/log/entries/0/cache", }), ); @@ -85,7 +85,7 @@ it.each([-1, -0.5])("rejects negative HAR elapsed time: %s", (time) => { ), ).toThrow( expect.objectContaining({ - reason: "format", + failure: expect.objectContaining({ kind: "malformed" }), pointer: "/log/entries/0/time", }), ); @@ -111,7 +111,7 @@ it.each(["onContentLoad", "onLoad"])( ), ).toThrow( expect.objectContaining({ - reason: "format", + failure: expect.objectContaining({ kind: "malformed" }), pointer: `/log/pages/0/pageTimings/${field}`, }), ); @@ -965,7 +965,9 @@ it.each([ if (accepted) expect(decodeHarCapture(text, []).total_records).toBe(0); else expect(() => decodeHarCapture(text, [])).toThrow( - expect.objectContaining({ reason: "input-limit" }), + expect.objectContaining({ + limit: { name: "nesting-depth", maximum: 64 }, + }), ); }, ); @@ -978,7 +980,9 @@ it("rejects excessive extension nesting instead of returning a partial capture", JSON.stringify({ ...historicalHar(), _extension: extension }), [], ), - ).toThrow(expect.objectContaining({ reason: "input-limit" })); + ).toThrow( + expect.objectContaining({ limit: { name: "nesting-depth", maximum: 64 } }), + ); }); it.each([ diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts index ec12dceceb..c8d29d8c87 100644 --- a/src/browser/history/HarCapture.ts +++ b/src/browser/history/HarCapture.ts @@ -46,7 +46,7 @@ export const decodeHarCapture = ( !captureObject(raw.log) || !Array.isArray(raw.log.entries) ) - throw new CaptureFormatError("format", "HAR must contain log.entries."); + throw new CaptureFormatError("malformed", "HAR must contain log.entries."); if (raw.log.version !== "1.2") throw new CaptureFormatError( "unsupported", @@ -142,7 +142,7 @@ const projectHar = ( ): JsonValue => { if (depth > captureBudgets().depth) throw new CaptureFormatError( - "input-limit", + { name: "nesting-depth", maximum: captureBudgets().depth }, `HAR exceeds max_nesting_depth (${String(captureBudgets().depth)} levels).`, pointer, ); @@ -240,7 +240,7 @@ const projectHar = ( }), ); throw new CaptureFormatError( - "format", + "malformed", "HAR parser returned a non-JSON value.", pointer, ); @@ -280,7 +280,7 @@ const decodeHarBase64 = (value: string, pointer: string): Buffer => { const compact = value.replace(/[\t\n\r ]/g, ""); if (!isCanonicalBase64(compact)) throw new CaptureFormatError( - "format", + "malformed", "HAR content declares base64 but its text is not valid canonical base64 (ASCII whitespace is allowed).", pointer, ); diff --git a/src/browser/history/HarSchema.ts b/src/browser/history/HarSchema.ts index 43d5422a0d..1799677c42 100644 --- a/src/browser/history/HarSchema.ts +++ b/src/browser/history/HarSchema.ts @@ -102,7 +102,7 @@ export const createHarValidator = (): (( // AJV messages name the violated constraint, such as the missing // property, without restating any captured value. throw new CaptureFormatError( - "format", + "malformed", error === undefined ? "HAR schema validation failed: unknown constraint." : `HAR schema validation failed: ${error.keyword} (${error.message ?? "no detail"}).`, diff --git a/src/browser/history/HistoricalCaptureDecoder.ts b/src/browser/history/HistoricalCaptureDecoder.ts index f8ff159a87..c8e8b04645 100644 --- a/src/browser/history/HistoricalCaptureDecoder.ts +++ b/src/browser/history/HistoricalCaptureDecoder.ts @@ -5,11 +5,16 @@ import { decodeCaptureProtobuf, prepareCaptureProtobuf, } from "./protobuf/ProtobufCapture.js"; -import { CaptureFormatError } from "./CaptureFormatError.js"; +import { + CAPTURE_LIMITS, + CaptureFormatError, + captureLimitForReply, +} from "./CaptureFormatError.js"; import { CAPTURE_PROCESS_MEMORY_LIMIT_REASON, historicalCaptureFailure, captureMemoryFailure, + protobufCaptureFailure, withCaptureReaderCleanup, } from "./CaptureFailures.js"; import { @@ -245,19 +250,17 @@ export class HistoricalCaptureDecoder { message: reply.message, }, ]); - if (reply.reason === "input-limit") + const limit = captureLimitForReply(reply.reason); + if (limit !== undefined) { + const { parameter, expected } = CAPTURE_LIMITS[limit]; + const budget = webNetworkCaptureBudgetFacts(input, parameter); throw new AnalysisInputError( OPERATION, { - diagnostics: { - budget: webNetworkCaptureBudgetFacts( - input, - "max_nesting_depth", - ), - }, + diagnostics: { budget }, remediationAction: webNetworkCaptureBudgetRecovery( input, - "max_nesting_depth", + parameter, ), }, [ @@ -265,35 +268,11 @@ export class HistoricalCaptureDecoder { path: ["capture_path", reply.pointer], reason: "out_of_range", message: reply.message, - expected: { - maximum_capture_nesting: captureBudgets().depth, - }, - }, - ], - ); - if (reply.reason === "expanded-limit") - throw new AnalysisInputError( - OPERATION, - { - diagnostics: { - budget: webNetworkCaptureBudgetFacts(input, "max_input_bytes"), - }, - remediationAction: webNetworkCaptureBudgetRecovery( - input, - "max_input_bytes", - ), - }, - [ - { - path: ["capture_path", reply.pointer], - reason: "out_of_range", - message: reply.message, - expected: { - maximum_expanded_capture_bytes: captureBudgets().inputBytes, - }, + expected: { [expected]: budget.effective_value }, }, ], ); + } if (reply.reason === "resource-limit") throw captureMemoryFailure( input, @@ -388,37 +367,15 @@ export class HistoricalCaptureDecoder { ); } catch (cause) { const error = - cause instanceof CaptureFormatError && cause.reason === "limit" - ? new AnalysisResourceConstraintError( - OPERATION, - "capacity", - cause.message, - { - maximum_encoded_capture_bytes: captureBudgets().outputBytes, - }, - { - retryable: false, - remediationAction: webNetworkCaptureBudgetRecovery( - input, - "max_reply_bytes", - ), - }, - ) - : cause instanceof CaptureFormatError - ? new AnalysisInputError(OPERATION, undefined, [ - { - path: ["protobuf"], - reason: "invalid_format", - message: cause.message, - }, - ]) - : historicalCaptureFailure( - input, - cause, - phase, - options, - reportedHeap, - ); + cause instanceof CaptureFormatError + ? protobufCaptureFailure(input, cause) + : historicalCaptureFailure( + input, + cause, + phase, + options, + reportedHeap, + ); if (safePartialSource) error.retainPartialObservation(result.value); throw error; } diff --git a/src/browser/history/NodeCaptureProcess.ts b/src/browser/history/NodeCaptureProcess.ts index 1887785dbe..b5f5b46eaa 100644 --- a/src/browser/history/NodeCaptureProcess.ts +++ b/src/browser/history/NodeCaptureProcess.ts @@ -49,7 +49,7 @@ const main = async (): Promise => { cause instanceof CaptureFormatError ? { ok: false, - reason: cause.reason, + reason: cause.replyReason, message: cause.message, pointer: cause.pointer, } diff --git a/src/browser/history/QlogCapture.test.ts b/src/browser/history/QlogCapture.test.ts index cf15b976c0..689760bd45 100644 --- a/src/browser/history/QlogCapture.test.ts +++ b/src/browser/history/QlogCapture.test.ts @@ -443,7 +443,9 @@ it.each([ if (accepted) expect(facts(text).decoded.total_records).toBe(0); else expect(() => facts(text)).toThrow( - expect.objectContaining({ reason: "input-limit" }), + expect.objectContaining({ + limit: { name: "nesting-depth", maximum: 64 }, + }), ); }, ); @@ -457,7 +459,9 @@ it("rejects other qlog containers/versions and excessive nesting, accepts empty extension: JSON.parse("[".repeat(65) + "0" + "]".repeat(65)), }), ), - ).toThrow(expect.objectContaining({ reason: "input-limit" })); + ).toThrow( + expect.objectContaining({ limit: { name: "nesting-depth", maximum: 64 } }), + ); expect( facts('{"qlog_format":"JSON","qlog_version":"0.3","traces":[]}').traces, ).toEqual([]); diff --git a/src/browser/history/QlogCapture.ts b/src/browser/history/QlogCapture.ts index e8170d15fe..f6a55d31a2 100644 --- a/src/browser/history/QlogCapture.ts +++ b/src/browser/history/QlogCapture.ts @@ -374,7 +374,7 @@ const packetEvent = (name: unknown): boolean => const object = (value: unknown): value is Record => captureObject(value) && !(value instanceof LosslessNumber); const invalid = (message: string, pointer: string): never => { - throw new CaptureFormatError("format", message, pointer); + throw new CaptureFormatError("malformed", message, pointer); }; /** Canonicalize JSON integer notation exactly, checking expansion before allocation. */ diff --git a/src/browser/history/SazCapture.test.ts b/src/browser/history/SazCapture.test.ts index 0ad819d7ef..c61ab81b32 100644 --- a/src/browser/history/SazCapture.test.ts +++ b/src/browser/history/SazCapture.test.ts @@ -99,7 +99,7 @@ it("keeps rejecting distinct source names that collide on one logical session me const path = join(root, "capture.saz"); await writeFile(path, Buffer.from(await writer.close())); await expect(decodeSazCapture(path, [])).rejects.toMatchObject({ - reason: "format", + failure: { kind: "malformed" }, message: "SAZ contains duplicate archive member names.", }); }); diff --git a/src/browser/history/SazCapture.ts b/src/browser/history/SazCapture.ts index 72165355d1..fa569c62bc 100644 --- a/src/browser/history/SazCapture.ts +++ b/src/browser/history/SazCapture.ts @@ -45,7 +45,7 @@ export const decodeSazCapture = async ( const sourcePath = entry.sourcePath ?? entry.path; if (paths.has(entry.path)) throw new CaptureFormatError( - "format", + "malformed", "SAZ contains duplicate archive member names.", ); paths.add(entry.path); @@ -61,12 +61,15 @@ export const decodeSazCapture = async ( ); if (entry.declaredSize !== null) { if (!Number.isSafeInteger(entry.declaredSize) || entry.declaredSize < 0) - throw new CaptureFormatError("format", "SAZ member size is invalid."); + throw new CaptureFormatError( + "malformed", + "SAZ member size is invalid.", + ); declaredBytes += entry.declaredSize; } if (declaredBytes > captureBudgets().inputBytes) throw new CaptureFormatError( - "expanded-limit", + { name: "expanded-input", maximum: captureBudgets().inputBytes }, `SAZ expanded members exceed max_input_bytes (${String(captureBudgets().inputBytes)} bytes); raise that per-call allowance and retry.`, ); inventory.push({ @@ -103,7 +106,7 @@ export const decodeSazCapture = async ( } if (groups.size === 0) throw new CaptureFormatError( - "format", + "malformed", "Archive contains no SAZ session members.", ); const records: WebNetworkCaptureRecord[] = []; @@ -125,20 +128,20 @@ export const decodeSazCapture = async ( for await (const chunk of stream) { if (!Buffer.isBuffer(chunk)) throw new CaptureFormatError( - "format", + "malformed", "SAZ member stream returned non-byte data.", ); length += chunk.length; if (length > (entry.declaredSize ?? 0)) throw new CaptureFormatError( - "format", + "malformed", "SAZ member exceeds its declared size.", ); chunks.push(chunk); } if (length !== entry.declaredSize) throw new CaptureFormatError( - "format", + "malformed", "SAZ member does not match its declared size.", ); const bytes = Buffer.concat(chunks, length); @@ -190,11 +193,11 @@ export const decodeSazCapture = async ( if (cause instanceof CaptureFormatError) throw cause; if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") throw new CaptureFormatError( - "expanded-limit", + { name: "expanded-input", maximum: captureBudgets().inputBytes }, "SAZ archive exceeds the ZIP reader's resource budget.", ); throw new CaptureFormatError( - "format", + "malformed", "SAZ archive or session data is malformed or fails ZIP integrity validation.", ); } finally { diff --git a/src/browser/history/SazHttp.ts b/src/browser/history/SazHttp.ts index 246dd61645..3e4f63f68e 100644 --- a/src/browser/history/SazHttp.ts +++ b/src/browser/history/SazHttp.ts @@ -15,7 +15,7 @@ export const decodeSazHttp = (bytes: Buffer, request: boolean) => { const boundary = split < 0 ? lfSplit : split; if (boundary < 0) throw new CaptureFormatError( - "format", + "malformed", "SAZ HTTP message has no header/body separator.", ); const lines = bytes @@ -27,14 +27,17 @@ export const decodeSazHttp = (bytes: Buffer, request: boolean) => { ? /^(\S+) +(\S+) +(HTTP\/\d+(?:\.\d+)?)$/.exec(first) : /^(HTTP\/\d+(?:\.\d+)?) +(\d{3})(?: (.*))?$/.exec(first); if (fields === null) - throw new CaptureFormatError("format", "SAZ HTTP start line is malformed."); + throw new CaptureFormatError( + "malformed", + "SAZ HTTP start line is malformed.", + ); const headers: { name: string; value: string }[] = []; for (const line of lines) { if (/^[ \t]/.test(line)) { const previous = headers.at(-1); if (previous === undefined) throw new CaptureFormatError( - "format", + "malformed", "SAZ HTTP header continuation has no field.", ); previous.value += `${split < 0 ? "\n" : "\r\n"}${line}`; @@ -44,11 +47,14 @@ export const decodeSazHttp = (bytes: Buffer, request: boolean) => { // HTTP/2 pseudo-headers use the second colon as the delimiter. const delimiter = colon === 0 ? line.indexOf(":", 1) : colon; if (delimiter < 1) - throw new CaptureFormatError("format", "SAZ HTTP header is malformed."); + throw new CaptureFormatError( + "malformed", + "SAZ HTTP header is malformed.", + ); const name = line.slice(0, delimiter); if (!/^:?[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) throw new CaptureFormatError( - "format", + "malformed", "SAZ HTTP header name is malformed.", ); headers.push({ name, value: line.slice(delimiter + 1) }); diff --git a/src/browser/history/SazMetadata.test.ts b/src/browser/history/SazMetadata.test.ts index 76f88adf45..a304a16702 100644 --- a/src/browser/history/SazMetadata.test.ts +++ b/src/browser/history/SazMetadata.test.ts @@ -42,7 +42,7 @@ it.each([ ], ] as const)("rejects %s", (_label, bytes, reason) => { expect(() => decodeSazMetadata(bytes)).toThrow( - expect.objectContaining({ reason }), + expect.objectContaining({ replyReason: reason }), ); }); @@ -109,7 +109,7 @@ it("rejects DTDs without resolving entities", () => { ), ).toThrow( expect.objectContaining({ - reason: "unsupported", + replyReason: "unsupported", } satisfies Partial), ); }); diff --git a/src/browser/history/SazMetadata.ts b/src/browser/history/SazMetadata.ts index 7b1a9aa4a2..fd0025fda5 100644 --- a/src/browser/history/SazMetadata.ts +++ b/src/browser/history/SazMetadata.ts @@ -29,7 +29,7 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => { cause.reason === "encoding-mismatch" ) throw new CaptureFormatError( - "format", + "malformed", "SAZ metadata encoding declaration disagrees with its bytes.", "", cause, @@ -47,7 +47,7 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => { }); if (!parsed.ok) throw new CaptureFormatError( - parsed.error.reason === "doctype" ? "unsupported" : "format", + parsed.error.reason === "doctype" ? "unsupported" : "malformed", parsed.error.reason === "doctype" ? "SAZ metadata DTDs are unsupported." : "SAZ metadata is not well-formed XML.", @@ -57,13 +57,13 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => { const document = parsed.value; if (document.documentElement?.tagName !== "Session") throw new CaptureFormatError( - "format", + "malformed", "SAZ metadata must have a Session document element.", ); const visit = (node: Node, depth: number): JsonValue => { if (depth > captureBudgets().depth) throw new CaptureFormatError( - "input-limit", + { name: "nesting-depth", maximum: captureBudgets().depth }, `SAZ metadata exceeds max_nesting_depth (${String(captureBudgets().depth)} levels).`, ); const children: JsonValue[] = []; diff --git a/src/browser/history/SazProjection.ts b/src/browser/history/SazProjection.ts index 63ebc7e15d..3a75dbea3c 100644 --- a/src/browser/history/SazProjection.ts +++ b/src/browser/history/SazProjection.ts @@ -27,7 +27,7 @@ export const projectSazRecord = ( ): JsonValue => { if (depth > captureBudgets().depth) throw new CaptureFormatError( - "input-limit", + { name: "nesting-depth", maximum: captureBudgets().depth }, `SAZ projection exceeds max_nesting_depth (${String(captureBudgets().depth)} levels).`, pointer, ); diff --git a/src/browser/history/playwrightTrace/TraceStream.ts b/src/browser/history/playwrightTrace/TraceStream.ts index a3b67a94d2..b90cf57c21 100644 --- a/src/browser/history/playwrightTrace/TraceStream.ts +++ b/src/browser/history/playwrightTrace/TraceStream.ts @@ -17,7 +17,7 @@ import { type TraceEvent, } from "../../../domain/playwrightTrace/playwrightTrace.js"; import { captureObject } from "../CaptureRedaction.js"; -import { CaptureFormatError } from "../CaptureFormatError.js"; +import { CAPTURE_LIMITS, CaptureFormatError } from "../CaptureFormatError.js"; import { parseCaptureJson } from "../CaptureJson.js"; import { TraceInventory, traceIdentity } from "./TraceInventory.js"; import { traceLines, type TraceLineBudget } from "./TraceLines.js"; @@ -46,7 +46,7 @@ const readRecord = (bytes: Buffer): unknown => { ); } catch (cause) { throw new CaptureFormatError( - "format", + "malformed", "Trace line is not valid UTF-8.", "", cause, @@ -143,30 +143,27 @@ class TraceStreamRead { raw = readRecord(line.bytes); } catch (cause) { if (!(cause instanceof CaptureFormatError)) throw cause; - if (cause.reason === "input-limit") { + const { limit } = cause; + if (limit !== undefined) { // The parser names the allowance it exhausted: node count or depth. - const allowance = cause.allowance; + const { name, maximum } = limit; + const nesting = name === "nesting-depth"; throw new AnalysisResourceConstraintError( PLAYWRIGHT_TRACE_OPERATION, "memory", cause.message, - allowance === undefined - ? null - : { - [allowance.kind === "parser-nodes" - ? "parser_nodes" - : "nesting_levels"]: allowance.limit, - }, + { [nesting ? "nesting_levels" : "parser_nodes"]: maximum }, { cause, ...fixedAllowanceOptions( - allowance?.kind === "nesting-depth" + nesting ? "The Playwright trace record nesting allowance" : "The Playwright trace record parser allowance", "parser", - allowance === undefined - ? {} - : { limit: allowance.limit, unit: allowance.unit }, + { + limit: maximum, + unit: CAPTURE_LIMITS[name].unit, + }, ), }, ); diff --git a/src/browser/history/protobuf/ProtobufCapture.ts b/src/browser/history/protobuf/ProtobufCapture.ts index 2bdd7f658e..06635957e5 100644 --- a/src/browser/history/protobuf/ProtobufCapture.ts +++ b/src/browser/history/protobuf/ProtobufCapture.ts @@ -421,7 +421,7 @@ export const prepareCaptureProtobuf = async ( Math.ceil(captureBudgets().inputBytes / 3) * 4 ) throw new CaptureFormatError( - "limit", + { name: "protobuf-body", maximum: captureBudgets().inputBytes }, `Protobuf body exceeds max_input_bytes (${String(captureBudgets().inputBytes)} bytes)`, ); const body = Buffer.from(field.content_base64, "base64"); diff --git a/src/browser/history/protobuf/ProtobufFraming.ts b/src/browser/history/protobuf/ProtobufFraming.ts index 44a2f1679d..ee4e0984a0 100644 --- a/src/browser/history/protobuf/ProtobufFraming.ts +++ b/src/browser/history/protobuf/ProtobufFraming.ts @@ -94,7 +94,7 @@ export const prepareProtobufBody = async ( ): Promise => { if (body.length > captureBudgets().inputBytes) throw new CaptureFormatError( - "limit", + { name: "protobuf-body", maximum: captureBudgets().inputBytes }, `Protobuf body exceeds max_input_bytes (${String(captureBudgets().inputBytes)} bytes)`, ); const work = new ProtobufBudget(signal); diff --git a/src/browser/history/protobuf/ProtobufWire.test.ts b/src/browser/history/protobuf/ProtobufWire.test.ts index cbd8691d16..156a7a4fd8 100644 --- a/src/browser/history/protobuf/ProtobufWire.test.ts +++ b/src/browser/history/protobuf/ProtobufWire.test.ts @@ -174,7 +174,7 @@ it("bounds qualified descriptor-name expansion before creating the SDK registry" ); await expect( ProtobufSchema.read(bytes, "Missing", new ProtobufBudget(), same), - ).rejects.toMatchObject({ reason: "limit" }); + ).rejects.toMatchObject({ limit: { name: "protobuf-decoding" } }); }); it("distinguishes native gRPC flags from web trailers and retains partial messages", async () => { @@ -376,7 +376,7 @@ it("stops before exhausting fact capacity and yields for caller cancellation", a budget.charge(96 * 1024 * 1024 - 1023); await expect( decodeProtobufWire(Buffer.from([8, 1]), 0, 2, 0, budget, same), - ).rejects.toMatchObject({ reason: "limit" }); + ).rejects.toMatchObject({ limit: { name: "protobuf-decoding" } }); const abort = new AbortController(); const reason = new Error("caller stopped selected decoding"); const data = Buffer.alloc(8192); diff --git a/src/browser/history/protobuf/ProtobufWire.ts b/src/browser/history/protobuf/ProtobufWire.ts index af341b3886..fead55ae0d 100644 --- a/src/browser/history/protobuf/ProtobufWire.ts +++ b/src/browser/history/protobuf/ProtobufWire.ts @@ -12,7 +12,10 @@ export class ProtobufBudget { charge(bytes: number): void { if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining) throw new CaptureFormatError( - "limit", + { + name: "protobuf-decoding", + maximum: captureBudgets().outputBytes, + }, `Protobuf decoding exceeds max_reply_bytes (${String(captureBudgets().outputBytes)} bytes); raise that per-call allowance and retry.`, ); this.#remaining -= bytes;