refactor(xml): centralize strict document parsing

Own syntax validation and source-located DOM projection together. Preserve explicit consumer policies and remove duplicated warning-message filters from Apktool and SAZ.

Validation: 92 focused checks, 3 selected fake Apktool boundary checks, check:fast and docs:check. No real Apktool or Ghidra execution.

Refs #4694
This commit is contained in:
morluto committed 2026-10-17 15:16:24 +08:00
1 parent c0b3cd14b1
commit 623339a7ab
4 files changed
+161 -83

No files matched your search

+10 -23
View File
@@ -1,9 +1,5 @@
import {
DOMParser,
type Document,
type Element,
type Node,
} from "@xmldom/xmldom";
import type { Document, Element, Node } from "@xmldom/xmldom";
import { parseXmlDocument } from "../domain/xmlDocument.js";
import {
isMap,
isScalar,
@@ -147,25 +143,16 @@ const yamlScalar = (
};
const parseXml = (xml: string, label: string) => {
const errors: string[] = [];
const document = new DOMParser({
normalizeLineEndings: (source) => source,
onError: (level, message) => {
// U+FFFD is a legal XML character. The provider's fatal UTF-8 decoder
// already rejects invalid bytes; this warning alone is not malformed XML.
if (
level === "warning" &&
message.startsWith("Unicode replacement character detected")
)
return;
errors.push(message);
},
}).parseFromString(xml, "application/xml");
if (errors.length > 0 || document.documentElement === null)
const parsed = parseXmlDocument(xml, {
doctype: "allow",
lineEndings: "preserve",
});
if (!parsed.ok)
throw new TypeError(
`${label} is not well-formed XML${errors[0] === undefined ? "" : `: ${errors[0]}`}`,
`${label} is not well-formed XML: ${parsed.error.message}`,
{ cause: parsed.error },
);
return document;
return parsed.value;
};
const childNodes = (node: Node): Node[] => {
+14 -60
View File
@@ -1,5 +1,5 @@
import { DOMParser, ParseError, type Node } from "@xmldom/xmldom";
import { SaxesParser } from "saxes";
import type { Node } from "@xmldom/xmldom";
import { parseXmlDocument } from "../../domain/xmlDocument.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js";
import {
@@ -41,36 +41,20 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => {
cause,
);
}
validateSazXml(xml);
let parseFailed = false;
let document: ReturnType<DOMParser["parseFromString"]>;
try {
document = new DOMParser({
normalizeLineEndings: (source) => source,
onError: (level, message) => {
// The parser warns whenever the source contains U+FFFD. It is a legal
// XML Char, and the fatal decoder above already rejected invalid
// source bytes, so only that warning is not a well-formedness failure.
if (
level === "warning" &&
message.startsWith("Unicode replacement character detected")
)
return;
parseFailed = true;
},
}).parseFromString(xml, "application/xml");
} catch (cause: unknown) {
if (!(cause instanceof ParseError)) throw cause;
const parsed = parseXmlDocument(xml, {
doctype: "reject",
lineEndings: "preserve",
});
if (!parsed.ok)
throw new CaptureFormatError(
"format",
"SAZ metadata is not well-formed XML.",
);
}
if (parseFailed)
throw new CaptureFormatError(
"format",
"SAZ metadata is not well-formed XML.",
parsed.error.reason === "doctype" ? "unsupported" : "format",
parsed.error.reason === "doctype"
? "SAZ metadata DTDs are unsupported."
: "SAZ metadata is not well-formed XML.",
"",
parsed.error,
);
const document = parsed.value;
if (document.documentElement?.tagName !== "Session")
throw new CaptureFormatError(
"format",
@@ -104,33 +88,3 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => {
};
return visit(document, 0);
};
/** Validate well-formedness before the DOM parser projects retained evidence. */
const validateSazXml = (xml: string): void => {
const parser = new SaxesParser({
defaultXMLVersion: "1.0",
forceXMLVersion: true,
});
let hasDoctype = false;
parser.on("doctype", () => {
hasDoctype = true;
});
try {
parser.write(xml).close();
} catch {
if (hasDoctype)
throw new CaptureFormatError(
"unsupported",
"SAZ metadata DTDs are unsupported.",
);
throw new CaptureFormatError(
"format",
"SAZ metadata is not well-formed XML.",
);
}
if (hasDoctype)
throw new CaptureFormatError(
"unsupported",
"SAZ metadata DTDs are unsupported.",
);
};
+75
View File
@@ -0,0 +1,75 @@
import { expect, it } from "vitest";
import { parseXmlDocument } from "./xmlDocument.js";
it.each([
'<root attr="�">😀 � &amp; &#xFFFD;</root>',
"<root><!-- � --><![CDATA[�]]></root>",
'<?xml version="1.0"?><root>\r\n �\rtext</root>',
"<!DOCTYPE root><root>�</root>",
'<!DOCTYPE root [<!ENTITY unused "�">]><root>�</root>',
])("preserves legal Unicode and source-located DOM nodes: %j", (source) => {
const parsed = parseXmlDocument(source, {
doctype: "allow",
lineEndings: "preserve",
});
if (!parsed.ok) throw parsed.error;
expect(parsed.value.documentElement?.tagName).toBe("root");
expect(parsed.value.documentElement?.lineNumber).toBeDefined();
expect(parsed.value.documentElement?.textContent).toContain("�");
});
it.each([
"<root attr=unquoted>�</root>",
'<root a="x"b="�"/>',
'<root attr="a" attr="�"/>',
"<root><broken>�</root>",
"<root>� & unknown</root>",
"<root>� &missing;</root>",
"<root>�\u0000</root>",
"<root>�&#xD800;</root>",
"<root>�\uFFFF</root>",
"<root>�]]></root>",
"<root><!-- invalid -- comment � --></root>",
"<root>�</root><second/>",
"<!DOCTYPE root [BOGUS]><root>�</root>",
"<!DOCTYPE root [<!ELEMENT broken>]><root>�</root>",
"<!DOCTYPE root SYSTEM><root>�</root>",
])("rejects malformed XML without warning-message matching: %j", (source) => {
const parsed = parseXmlDocument(source, {
doctype: "allow",
lineEndings: "preserve",
});
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected refusal");
expect(parsed.error.reason).toBe("malformed");
expect(parsed.error.cause).toBeDefined();
});
it("keeps literal line endings and character-reference values distinct", () => {
const parsed = parseXmlDocument("<root>\r\n&#13;\r�</root>", {
doctype: "allow",
lineEndings: "preserve",
});
if (!parsed.ok) throw parsed.error;
expect(parsed.value.documentElement?.textContent).toBe("\r\n\r\r�");
});
it("applies the consumer's explicit DTD policy", () => {
const parsed = parseXmlDocument(
'<!DOCTYPE root [<!ENTITY value "unused">]><root/>',
{ doctype: "reject", lineEndings: "preserve" },
);
expect(parsed.ok).toBe(false);
if (parsed.ok) throw new Error("Expected unsupported DTD");
expect(parsed.error.reason).toBe("doctype");
});
it("applies XML line-ending normalization only when the consumer selects it", () => {
const parsed = parseXmlDocument('<root attr="a\r\nb">\r\nx</root>', {
doctype: "allow",
lineEndings: "normalize",
});
if (!parsed.ok) throw parsed.error;
expect(parsed.value.documentElement?.getAttribute("attr")).toBe("a b");
expect(parsed.value.documentElement?.textContent).toBe("\nx");
});
+62
View File
@@ -0,0 +1,62 @@
import {
DOMParser,
normalizeLineEndings,
onErrorStopParsing,
type Document,
} from "@xmldom/xmldom";
import { SaxesParser } from "saxes";
import { err, ok, type Result } from "./result.js";
export class XmlDocumentParseError extends Error {
readonly _tag = "XmlDocumentParseError";
constructor(
readonly reason: "malformed" | "doctype",
cause?: unknown,
) {
super(
reason === "doctype"
? "XML document types are unsupported"
: `XML is not well-formed${cause instanceof Error ? `: ${cause.message}` : ""}`,
{ cause },
);
}
}
/** One boundary owns strict XML syntax and the source-located DOM projection. */
export const parseXmlDocument = (
source: string,
policy: {
readonly doctype: "allow" | "reject";
readonly lineEndings: "preserve" | "normalize";
},
): Result<Document, XmlDocumentParseError> => {
try {
const syntax = new SaxesParser({
defaultXMLVersion: "1.0",
forceXMLVersion: true,
});
syntax.on("doctype", () => {
if (policy.doctype === "reject")
throw new XmlDocumentParseError("doctype");
});
syntax.write(source).close();
// Saxes owns well-formedness outside DTDs. The DOM parser additionally
// checks DTD syntax and builds source-located nodes; its advisory warnings
// (including literal U+FFFD) are not byte-decoding or syntax failures.
return ok(
new DOMParser({
normalizeLineEndings:
policy.lineEndings === "preserve"
? (text) => text
: normalizeLineEndings,
onError: onErrorStopParsing,
}).parseFromString(source, "application/xml"),
);
} catch (cause: unknown) {
return err(
cause instanceof XmlDocumentParseError
? cause
: new XmlDocumentParseError("malformed", cause),
);
}
};