diff --git a/src/domain/propertyListXml.test.ts b/src/domain/propertyListXml.test.ts index 5fb361563f..571ba163d6 100644 --- a/src/domain/propertyListXml.test.ts +++ b/src/domain/propertyListXml.test.ts @@ -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(`${text}`); + expect(parsed.value).toEqual(Uint8Array.from(expected)); +}); + it("collects source integers and CDATA reals before rounding and duplicate assignment", () => { const parsed = parseXmlPropertyList( "a0x20000000000001a9007199254740992b9007199254", diff --git a/src/domain/propertyListXml.ts b/src/domain/propertyListXml.ts index f7e123b9cc..6925bac81d 100644 --- a/src/domain/propertyListXml.ts +++ b/src/domain/propertyListXml.ts @@ -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); diff --git a/tests/acceptance/applications/keyedArchiveXmlNumbers.test.ts b/tests/acceptance/applications/keyedArchiveXmlNumbers.test.ts index c53ffa252c..387eb73a94 100644 --- a/tests/acceptance/applications/keyedArchiveXmlNumbers.test.ts +++ b/tests/acceptance/applications/keyedArchiveXmlNumbers.test.ts @@ -34,8 +34,18 @@ cliTest.for([ element: `1${"0".repeat(400)}`, expected: { $plist_type: "integer", decimal: `1${"0".repeat(400)}` }, }, + { + name: "incomplete data quartet", + element: "AQI", + expected: { $plist_type: "data", base64: "" }, + }, + { + name: "data with a non-alphabet character", + element: "AQ!ID", + 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(