refactor(capture): report decoder failures through ParseFailure with named limits (#10708)

This commit is contained in:
morluto authored and GitHub committed 2026-11-15 10:13:55 +08:00
1 parent 44fedc8fdd
commit d9daf09ea5
23 files changed
+341 -156

No files matched your search

@@ -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,
+65 -1
View File
@@ -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();
});
+38
View File
@@ -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";
+133 -17
View File
@@ -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";
}
}
+8 -14
View File
@@ -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.`,
);
}
+10 -6
View File
@@ -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([
+4 -4
View File
@@ -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,
);
+1 -1
View File
@@ -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"}).`,
+23 -66
View File
@@ -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;
}
+1 -1
View File
@@ -49,7 +49,7 @@ const main = async (): Promise<void> => {
cause instanceof CaptureFormatError
? {
ok: false,
reason: cause.reason,
reason: cause.replyReason,
message: cause.message,
pointer: cause.pointer,
}
+6 -2
View File
@@ -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([]);
+1 -1
View File
@@ -374,7 +374,7 @@ const packetEvent = (name: unknown): boolean =>
const object = (value: unknown): value is Record<string, unknown> =>
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. */
+1 -1
View File
@@ -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.",
});
});
+12 -9
View File
@@ -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 {
+11 -5
View File
@@ -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) });
+2 -2
View File
@@ -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<CaptureFormatError>),
);
});
+4 -4
View File
@@ -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[] = [];
+1 -1
View File
@@ -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,
);
@@ -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,
},
),
},
);
@@ -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");
@@ -94,7 +94,7 @@ export const prepareProtobufBody = async (
): Promise<PreparedProtobufBody> => {
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);
@@ -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);
+4 -1
View File
@@ -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;