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(