fix(plist): decode XML data as CoreFoundation does

XML <data> used WHATWG atob, which pads an incomplete trailing quartet into bytes the native reader never produces and refuses non-alphabet characters that CFPropertyList skips. Decode at the shared XML value owner by skipping non-alphabet characters and emitting only completed quartets, shortened by the trailing padding run.

Fixes #4978
This commit is contained in:
Avichal Pandey committed 2026-10-18 12:08:42 +05:30
1 parent b83b9caf4a
commit 87bfcbd70f
3 files changed
+53 -5

No files matched your search

+14
View File
@@ -24,6 +24,20 @@ it.each([
},
);
it.each([
["AQID", [1, 2, 3]],
[" A Q I D\n", [1, 2, 3]],
["AQI", []],
["AQ!ID", [1, 2, 3]],
["AQ==", [1]],
["AQI=", [1, 2]],
["AQ= =\n", [1]],
["", []],
])("decodes data text %j as CFPropertyList does", (text, expected) => {
const parsed = parseXmlPropertyList(`<plist><data>${text}</data></plist>`);
expect(parsed.value).toEqual(Uint8Array.from(expected));
});
it("collects source integers and CDATA reals before rounding and duplicate assignment", () => {
const parsed = parseXmlPropertyList(
"<plist><dict><key>a</key><integer>0x20000000000001</integer><key>a</key><integer>9007199254740992</integer><key>b</key><real>9007199254<![CDATA[740992]]></real></dict></plist>",
+28 -4
View File
@@ -7,6 +7,32 @@ import {
parsePlistNonFiniteRealLiteral,
} from "./apple/plistNumbers.js";
const BASE64_DIGITS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const C_WHITESPACE = new Set([" ", "\t", "\n", "\v", "\f", "\r"]);
// CFPropertyList skips non-alphabet characters and emits only completed
// quartets; atob would pad or refuse data the native reader accepts.
const decodePlistData = (text: string): Uint8Array => {
const bytes: number[] = [];
let quartet = 0;
let digits = 0;
let padding = 0;
for (const character of text) {
if (character === "=") padding += 1;
else if (!C_WHITESPACE.has(character)) padding = 0;
const digit = character === "=" ? 0 : BASE64_DIGITS.indexOf(character);
if (digit < 0) continue;
quartet = ((quartet << 6) | digit) & 0xffffff;
digits += 1;
if (digits % 4 !== 0) continue;
bytes.push(quartet >> 16);
if (padding < 2) bytes.push((quartet >> 8) & 0xff);
if (padding < 1) bytes.push(quartet & 0xff);
}
return Uint8Array.from(bytes);
};
/** Interpret an already validated, depth-bounded DOM, owning values and number facts together. */
export const decodeXmlPropertyList = (document: Document) => {
const root = document.documentElement;
@@ -132,10 +158,8 @@ export const decodeXmlPropertyList = (document: Document) => {
if (literal.complete) numbers.real(value);
return value;
}
case "data": {
const binary = atob(content(node, textNode).text.replace(/\s+/gu, ""));
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
case "data":
return decodePlistData(content(node, textNode).text);
case "date": {
const value = requireContent(node).nodeValue;
return new Date(value === null ? 0 : value);
@@ -34,8 +34,18 @@ cliTest.for([
element: `<integer>1${"0".repeat(400)}</integer>`,
expected: { $plist_type: "integer", decimal: `1${"0".repeat(400)}` },
},
{
name: "incomplete data quartet",
element: "<data>AQI</data>",
expected: { $plist_type: "data", base64: "" },
},
{
name: "data with a non-alphabet character",
element: "<data>AQ!ID</data>",
expected: { $plist_type: "data", base64: "AQID" },
},
])(
"preserves $name XML numbers through CLI and MCP",
"preserves $name XML scalars through CLI and MCP",
async (scenario, { cli }) => {
const root = await createTestTempDirectory("rea-keyed-xml-numbers-");
const bytes = Buffer.from(