diff --git a/src/browser/history/CaptureFormatError.ts b/src/browser/history/CaptureFormatError.ts
index c3e4da9b3e..0fdc026909 100644
--- a/src/browser/history/CaptureFormatError.ts
+++ b/src/browser/history/CaptureFormatError.ts
@@ -9,7 +9,8 @@ export class CaptureFormatError extends Error {
| "limit",
message: string,
readonly pointer = "",
+ cause?: unknown,
) {
- super(message);
+ super(message, cause === undefined ? undefined : { cause });
}
}
diff --git a/src/browser/history/SazMetadata.test.ts b/src/browser/history/SazMetadata.test.ts
index 4aa482a7a4..54126937c1 100644
--- a/src/browser/history/SazMetadata.test.ts
+++ b/src/browser/history/SazMetadata.test.ts
@@ -2,6 +2,50 @@ import { expect, it } from "vitest";
import { CaptureFormatError } from "./CaptureFormatError.js";
import { decodeSazMetadata } from "./SazMetadata.js";
+it.each([
+ ["UTF-8", Buffer.from('')],
+ [
+ "BOM-marked UTF-16LE",
+ Buffer.concat([
+ Buffer.from([0xff, 0xfe]),
+ Buffer.from(
+ '',
+ "utf16le",
+ ),
+ ]),
+ ],
+] as const)("accepts matching %s XML declarations", (_label, bytes) => {
+ expect(() => decodeSazMetadata(bytes)).not.toThrow();
+});
+
+it.each([
+ [
+ "UTF-16 declaration on UTF-8 bytes",
+ Buffer.from(''),
+ "format",
+ ],
+ [
+ "UTF-8 declaration on UTF-16 bytes",
+ Buffer.concat([
+ Buffer.from([0xff, 0xfe]),
+ Buffer.from(
+ '',
+ "utf16le",
+ ),
+ ]),
+ "format",
+ ],
+ [
+ "unsupported declared encoding",
+ Buffer.from(''),
+ "unsupported",
+ ],
+] as const)("rejects %s", (_label, bytes, reason) => {
+ expect(() => decodeSazMetadata(bytes)).toThrow(
+ expect.objectContaining({ reason }),
+ );
+});
+
it("retains U+FFFD in well-formed metadata as an ordinary XML character", () => {
const decoded = JSON.stringify(
decodeSazMetadata(
diff --git a/src/browser/history/SazMetadata.ts b/src/browser/history/SazMetadata.ts
index a5dd239bd0..5ae8d950b7 100644
--- a/src/browser/history/SazMetadata.ts
+++ b/src/browser/history/SazMetadata.ts
@@ -2,23 +2,43 @@ import { DOMParser, ParseError, type Node } from "@xmldom/xmldom";
import { SaxesParser } from "saxes";
import type { JsonValue } from "../../domain/jsonValue.js";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js";
+import {
+ decodeXmlPlistText,
+ XmlPlistTextDecodeError,
+} from "../../domain/propertyListXmlText.js";
import { CaptureFormatError } from "./CaptureFormatError.js";
/** Preserve XML node order, attribute names and text without interpreting producer-specific flags. */
export const decodeSazMetadata = (bytes: Buffer): JsonValue => {
let xml: string;
try {
- const encoding =
- bytes[0] === 0xff && bytes[1] === 0xfe
- ? "utf-16le"
- : bytes[0] === 0xfe && bytes[1] === 0xff
- ? "utf-16be"
- : "utf-8";
- xml = new TextDecoder(encoding, { fatal: true }).decode(bytes);
- } catch {
+ xml = decodeXmlPlistText(bytes);
+ } catch (cause: unknown) {
+ if (
+ cause instanceof XmlPlistTextDecodeError &&
+ cause.reason === "unsupported-encoding"
+ )
+ throw new CaptureFormatError(
+ "unsupported",
+ `SAZ metadata declares unsupported XML encoding ${cause.declaredEncoding}.`,
+ "",
+ cause,
+ );
+ if (
+ cause instanceof XmlPlistTextDecodeError &&
+ cause.reason === "encoding-mismatch"
+ )
+ throw new CaptureFormatError(
+ "format",
+ "SAZ metadata encoding declaration disagrees with its bytes.",
+ "",
+ cause,
+ );
throw new CaptureFormatError(
"unsupported",
"SAZ metadata must use UTF-8 or BOM-marked UTF-16.",
+ "",
+ cause,
);
}
validateSazXml(xml);
diff --git a/src/domain/propertyListXmlText.test.ts b/src/domain/propertyListXmlText.test.ts
new file mode 100644
index 0000000000..8cae86b390
--- /dev/null
+++ b/src/domain/propertyListXmlText.test.ts
@@ -0,0 +1,25 @@
+import { expect, it } from "vitest";
+
+import {
+ decodeXmlPlistText,
+ XmlPlistTextDecodeError,
+} from "./propertyListXmlText.js";
+
+it.each([
+ [
+ Buffer.from(''),
+ "encoding-mismatch",
+ ],
+ [
+ Buffer.from(''),
+ "unsupported-encoding",
+ ],
+ [Buffer.from([0xff, 0xfe, 0x3c]), "invalid-bytes"],
+] as const)("tags XML decoding failures as %s", (bytes, reason) => {
+ expect(() => decodeXmlPlistText(bytes)).toThrow(
+ expect.objectContaining({
+ name: "XmlPlistTextDecodeError",
+ reason,
+ } satisfies Partial),
+ );
+});
diff --git a/src/domain/propertyListXmlText.ts b/src/domain/propertyListXmlText.ts
index 2d6820120f..8cf52d772d 100644
--- a/src/domain/propertyListXmlText.ts
+++ b/src/domain/propertyListXmlText.ts
@@ -1,4 +1,19 @@
-/** Decode BOM-aware XML plist text with fatal byte validation. */
+export class XmlPlistTextDecodeError extends TypeError {
+ constructor(
+ readonly reason:
+ | "invalid-bytes"
+ | "unsupported-encoding"
+ | "encoding-mismatch",
+ message: string,
+ readonly declaredEncoding?: string,
+ options?: ErrorOptions,
+ ) {
+ super(message, options);
+ this.name = "XmlPlistTextDecodeError";
+ }
+}
+
+/** Decode BOM-aware XML plist text and verify any declaration against its bytes. */
export const decodeXmlPlistText = (bytes: Uint8Array): string => {
const encoding =
bytes[0] === 0xff && bytes[1] === 0xfe
@@ -6,7 +21,17 @@ export const decodeXmlPlistText = (bytes: Uint8Array): string => {
: bytes[0] === 0xfe && bytes[1] === 0xff
? "utf-16be"
: "utf-8";
- const text = new TextDecoder(encoding, { fatal: true }).decode(bytes);
+ let text: string;
+ try {
+ text = new TextDecoder(encoding, { fatal: true }).decode(bytes);
+ } catch (cause: unknown) {
+ throw new XmlPlistTextDecodeError(
+ "invalid-bytes",
+ "XML text bytes are invalid for the detected encoding",
+ undefined,
+ { cause },
+ );
+ }
const declaration = /^<\?xml\s[^?]*\bencoding\s*=\s*["']([^"']+)["']/u.exec(
text,
)?.[1];
@@ -15,14 +40,20 @@ export const decodeXmlPlistText = (bytes: Uint8Array): string => {
declared !== undefined &&
!["utf-8", "utf-16", "utf-16le", "utf-16be"].includes(declared)
)
- throw new TypeError(
+ throw new XmlPlistTextDecodeError(
+ "unsupported-encoding",
`Unsupported XML encoding declaration: ${declaration}. Supported encodings are UTF-8 and BOM-marked UTF-16 in either byte order.`,
+ declaration,
);
if (
declared !== undefined &&
declared !== encoding &&
!(encoding !== "utf-8" && declared === "utf-16")
)
- throw new TypeError("XML encoding declaration disagrees with its bytes");
+ throw new XmlPlistTextDecodeError(
+ "encoding-mismatch",
+ "XML encoding declaration disagrees with its bytes",
+ declaration,
+ );
return text;
};
diff --git a/tests/boundary/browser/sazWebCapture.test.ts b/tests/boundary/browser/sazWebCapture.test.ts
index d6192e2e69..3e16fac4c3 100644
--- a/tests/boundary/browser/sazWebCapture.test.ts
+++ b/tests/boundary/browser/sazWebCapture.test.ts
@@ -342,6 +342,51 @@ it("keeps CRLF separators inside a body after LF-only HTTP headers", async () =>
expect(await readFile(path)).toEqual(bytes);
});
+it("validates SAZ XML declarations against archive member bytes at the CLI boundary", async () => {
+ const withMetadata = (metadata: Buffer): [string, Buffer][] =>
+ members().map(([name, bytes]) =>
+ name === "raw/0042_m.xml" ? [name, metadata] : [name, bytes],
+ );
+ const utf8 = Buffer.from(
+ '',
+ );
+ const utf16le = Buffer.concat([
+ Buffer.from([0xff, 0xfe]),
+ Buffer.from(
+ '',
+ "utf16le",
+ ),
+ ]);
+ for (const metadata of [utf8, utf16le]) {
+ const result = await cli(
+ await fixture(await archive(withMetadata(metadata))),
+ );
+ expect(
+ webNetworkCaptureSchema.parse(result.normalized_result).records,
+ ).toHaveLength(2);
+ }
+ const mismatch = Buffer.from(
+ '',
+ );
+ const rejected = await cliResponse(
+ await fixture(await archive(withMetadata(mismatch))),
+ ["--record", "1"],
+ );
+ expect(rejected.ok).toBe(false);
+ expect(JSON.parse(rejected.stdout)).toMatchObject({
+ category: "invalid_input",
+ details: {
+ issues: [
+ expect.objectContaining({
+ message: expect.stringContaining(
+ "SAZ metadata encoding declaration disagrees with its bytes",
+ ),
+ }),
+ ],
+ },
+ });
+});
+
it("excludes explicit literals from XML, HTTP byte views, member coordinates and binary sidecars", async () => {
const path = await fixture(await archive());
const result = await cli(path, [