mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
refactor(capture): report decoder failures through ParseFailure with named limits (#10708)
This commit is contained in:
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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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"}).`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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>),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user