fix(plist): own XML values and numeric facts in one decoder

Interpret the validated, depth-bounded DOM directly. Collect source number
facts during the same traversal and share them with keyed archives, Interface
Builder and native plist projections. Remove the second XML parse, key aliases,
source-location rewriting and both duplicate XML numeric scanners. Preserve
character references, container/null behavior, safe own-property construction
and prototype omission accounting.

Decode signed hexadecimal integers and combine real text/CDATA. Retain exact
integer identity when a finite integer exceeds even Number's finite range,
using the shared scalar projector. Keep unsupported mixed integer syntax
conservative and report incomplete metadata.

Adapt @apitoagents's hexadecimal regression and literal-parsing proposal from
PR #3475. Native plutil controls establish the supported hex/real cases and
confirm that the synthetic 401-digit overflow case exceeds native bounds;
retaining accepted input does not claim native-format support.

Validation: 274 focused checks in 30 files pass, one skipped; build,
check:fast and docs:check pass. Built CLI/actual stdio MCP, native plist and
Interface Builder controls cover kinds, references, exact digits and digests.
Eight baseline checks fail; native binary conversion independently verifies
updated CDATA real expectations. 48 tiny completed CLI cost comparisons
preserve ordinary XML/binary and all three native replies byte for byte.
Two read-only reviews clear. No statistical speed claim, heavy profiler,
investigated-target execution or real deep-provider verification.

Fixes #3373
Fixes #4042
Fixes #4789
This commit is contained in:
morluto
2026-10-17 20:40:55 +08:00
parent 78cf6100f3
commit 7aa4b98c98
17 changed files with 542 additions and 303 deletions
+4
View File
@@ -587,6 +587,10 @@ digests identify the original serialized bytes.
## Keyed archive integer precision
The archive reader observes XML number element types and binary number markers.
XML values and their number facts are interpreted together from the validated
DOM. Hexadecimal `<integer>` text, including an optional sign, retains its value;
`<real>` text and CDATA segments are combined without inferring text from nested
elements. The XML path does not serialize or reparse character references.
Integers beyond the exact range of a JSON number are reported with the original
decimal text as `{ "$plist_type": "integer", "decimal": "<exact digits>" }` when
that source value can be associated unambiguously. Integral real values remain
@@ -39,7 +39,6 @@ import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
import {
decodeBinaryPlistNumbers,
scanBinaryPlistNumberLiterals,
xmlPlistNumberLiterals,
} from "./PlistNumberLiterals.js";
import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js";
import {
@@ -753,12 +752,13 @@ const decodePlist = (
const binaryNumberMetadata = binary
? scanBinaryPlistNumberLiterals(bytes)
: undefined;
const { value, omittedPrototypeKeys } = binary
const { value, omittedPrototypeKeys, numberLiterals } = binary
? {
value: parseBinary(
decodeBinaryPlistNumbers(bytes, binaryNumberMetadata!),
),
omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0,
numberLiterals: binaryNumberMetadata!.literals,
}
: parseXmlWithinDecodeBudget(xmlText ?? decodeXmlPlistText(bytes));
const projected = projectPlistValue(value);
@@ -766,8 +766,6 @@ const decodePlist = (
value: projected.value,
omittedPrototypeKeys,
unknownDateCount: projected.unknownDateCount,
numberLiterals: binary
? binaryNumberMetadata!.literals
: xmlPlistNumberLiterals(xmlText ?? decodeXmlPlistText(bytes)),
numberLiterals,
};
};
+2 -6
View File
@@ -11,7 +11,6 @@ import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js"
import {
decodeBinaryPlistNumbers,
scanBinaryPlistNumberLiterals,
xmlPlistNumberLiterals,
} from "./PlistNumberLiterals.js";
import {
omittedPrototypeKeysLimitation,
@@ -77,6 +76,7 @@ export const decodeKeyedArchiveBytes = (
value: parseBinary(
decodeBinaryPlistNumbers(bytes, binaryNumberMetadata!),
),
numberLiterals: binaryNumberMetadata!.literals,
omittedPrototypeKeys: binaryExpansion.binaryPrototypeKeyObserved
? null
: 0,
@@ -86,11 +86,7 @@ export const decodeKeyedArchiveBytes = (
parsed.value,
selection,
);
const numbers = createPlistNumberProjection(
binary
? binaryNumberMetadata!.literals
: xmlPlistNumberLiterals(xmlText ?? ""),
);
const numbers = createPlistNumberProjection(parsed.numberLiterals);
// Classify the serialized graph before projecting values to typed decimals.
// Numeric IDs, node kinds, references and pagination retain their original meaning.
const roots = Object.fromEntries(
+7 -124
View File
@@ -1,125 +1,9 @@
import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js";
import { decodeXmlText } from "../../domain/propertyListKeys.js";
import {
createPlistNumberCollector,
type PlistNumberLiterals,
} from "../../domain/apple/plistNumbers.js";
import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js";
const collector = () => {
const integers = new Map<number, Set<string>>();
const reals = new Set<number>();
let unsafeIntegerLiterals = 0;
let incompleteMetadata = false;
return {
integer: (literal: string) => {
if (!/^[+-]?\d+$/u.test(literal)) {
incompleteMetadata = true;
return;
}
const exact = BigInt(literal);
if (
exact >= BigInt(Number.MIN_SAFE_INTEGER) &&
exact <= BigInt(Number.MAX_SAFE_INTEGER)
)
return;
unsafeIntegerLiterals += 1;
const rounded = Number(exact);
integers.set(
rounded,
(integers.get(rounded) ?? new Set()).add(exact.toString()),
);
},
real: (value: number) => {
reals.add(value);
},
markIncomplete: () => {
incompleteMetadata = true;
},
result: (incomplete = false): PlistNumberLiterals => ({
incomplete: incomplete || incompleteMetadata,
integers,
reals,
unsafeIntegerLiterals,
}),
};
};
// Consume quoted tag attributes and opaque comment/CDATA tokens whole.
const XML_TOKEN =
/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<(?:[^>"']|"[^"]*"|'[^']*')*>/gu;
const OPAQUE_ELEMENTS = new Set(["string", "key", "data", "date"]);
/** Observe plist number element types without interpreting strings or class code. */
export const xmlPlistNumberLiterals = (xml: string): PlistNumberLiterals => {
const output = collector();
const elements: string[] = [];
let number:
| {
kind: "integer" | "real";
text: string;
depth: number;
plainText: boolean;
}
| undefined;
let cursor = 0;
for (const match of xml.matchAll(XML_TOKEN)) {
const index = match.index;
if (number !== undefined) number.text += xml.slice(cursor, index);
const token = match[0];
cursor = index + token.length;
if (token.startsWith("<!--") || token.startsWith("<![CDATA[")) {
if (number !== undefined) {
number.plainText = false;
output.markIncomplete();
}
continue;
}
const tag = /^<\s*(\/?)\s*([\w:-]+)/u.exec(token);
if (tag === null) {
if (number !== undefined) {
number.plainText = false;
output.markIncomplete();
}
continue;
}
const closing = tag[1] === "/";
const name = tag[2];
if (name === undefined) continue;
if (closing) {
if (
number !== undefined &&
number.kind === name &&
number.depth === elements.length
) {
if (number.plainText) {
const text = decodeXmlText(number.text).trim();
if (number.kind === "integer") output.integer(text);
else output.real(Number.parseFloat(text));
}
number = undefined;
}
elements.pop();
continue;
}
// The pinned decoder reads only the first child for integers and only text
// nodes for reals. Mixed content cannot establish an exact association.
if (number !== undefined) {
number.plainText = false;
output.markIncomplete();
}
if (/\/\s*>$/u.test(token)) continue;
if (
number === undefined &&
(name === "integer" || name === "real") &&
!elements.some((parent) => OPAQUE_ELEMENTS.has(parent))
)
number = {
kind: name,
text: "",
depth: elements.length + 1,
plainText: true,
};
elements.push(name);
}
return output.result();
};
export interface BinaryPlistNumberMetadata {
readonly literals: PlistNumberLiterals;
/** 16-byte integers the pinned plist decoder cannot decode correctly. */
@@ -179,7 +63,7 @@ const binaryObjectEnd = (
export const scanBinaryPlistNumberLiterals = (
bytes: Buffer,
): BinaryPlistNumberMetadata => {
const output = collector();
const output = createPlistNumberCollector();
const wideIntegers: { offset: number; value: bigint }[] = [];
if (bytes.length < 40 || !hasBinaryPlistSignature(bytes))
return { literals: output.result(true), wideIntegers };
@@ -236,14 +120,13 @@ export const scanBinaryPlistNumberLiterals = (
const width = 2 ** (marker & 0xf);
if (offset + 1 + width > offsetTable)
return { literals: output.result(true), wideIntegers };
if (type === 1 && width === 8)
output.integer(view.getBigInt64(offset + 1).toString());
if (type === 1 && width === 8) output.integer(view.getBigInt64(offset + 1));
else if (type === 1 && width === 16) {
const value = BigInt.asIntN(
128,
(view.getBigUint64(offset + 1) << 64n) | view.getBigUint64(offset + 9),
);
output.integer(value.toString());
output.integer(value);
wideIntegers.push({ offset, value });
} else if (type === 1 && width > 4)
return { literals: output.result(true), wideIntegers };
+50 -6
View File
@@ -1,15 +1,63 @@
import type { JsonValue } from "../jsonValue.js";
/** Exact decimal or signed hexadecimal XML integer text. */
export const parsePlistIntegerLiteral = (text: string): bigint | undefined => {
const match = /^([+-]?)(?:0[xX]([\da-fA-F]+)|(\d+))$/u.exec(text.trim());
if (match === null) return undefined;
const magnitude = BigInt(
match[2] === undefined ? match[3]! : `0x${match[2]}`,
);
return match[1] === "-" ? -magnitude : magnitude;
};
/** Source element types needed to distinguish large integers from integral reals. */
export interface PlistNumberLiterals {
readonly integers: ReadonlyMap<number, ReadonlySet<string>>;
readonly reals: ReadonlySet<number>;
/** Integer literals in the original serialized plist, not decoded graph slots. */
readonly unsafeIntegerLiterals: number;
/** Supplemental byte metadata was not completely observable. */
/** Source numeric facts could not all be established. */
readonly incomplete: boolean;
}
export const incompletePlistNumberLimitation =
"Property-list number metadata could not be completely observed; decoded numbers may lose integer precision and exact integer/real association is unavailable.";
/** Collect facts at the decoder boundary before integer values are rounded. */
export const createPlistNumberCollector = () => {
const integers = new Map<number, Set<string>>();
const reals = new Set<number>();
let unsafeIntegerLiterals = 0;
let incompleteMetadata = false;
return {
integer: (exact: bigint) => {
if (
exact >= BigInt(Number.MIN_SAFE_INTEGER) &&
exact <= BigInt(Number.MAX_SAFE_INTEGER)
)
return;
unsafeIntegerLiterals++;
const rounded = Number(exact);
integers.set(
rounded,
(integers.get(rounded) ?? new Set()).add(exact.toString()),
);
},
real: (value: number) => {
reals.add(value);
},
markIncomplete: () => {
incompleteMetadata = true;
},
result: (incomplete = false): PlistNumberLiterals => ({
integers,
reals,
unsafeIntegerLiterals,
incomplete: incomplete || incompleteMetadata,
}),
};
};
/** Preserve unambiguous exact integers; disclose rounded or unclassified numbers. */
export const createPlistNumberProjection = (literals: PlistNumberLiterals) => {
let exactIntegerCount = 0;
@@ -66,11 +114,7 @@ export const createPlistNumberProjection = (literals: PlistNumberLiterals) => {
return {
project: map,
limitations: () => [
...(literals.incomplete
? [
"Original archive number metadata could not be completely observed; decoded numbers may lose integer precision and exact integer/real association is unavailable.",
]
: []),
...(literals.incomplete ? [incompletePlistNumberLimitation] : []),
...(literals.unsafeIntegerLiterals === 0
? []
: [
+17
View File
@@ -86,3 +86,20 @@ it("rejects accessors, proxies, holes and symbol keys without invoking user code
expect(() => projectPlistValue(value)).toThrow(TypeError);
expect(accessed).toBe(0);
});
it("keeps retained integer scalars distinct from lookalike dictionaries", () => {
const decimal = `1${"0".repeat(400)}`;
const observed: object[] = [];
const projected = projectPlistValue(
[BigInt(decimal), { $plist_type: "integer", decimal }],
(value) => observed.push(value),
);
expect(projected.unknownRealCount).toBe(0);
expect(projected.unknownDateCount).toBe(0);
expect(Array.isArray(projected.value)).toBe(true);
if (!Array.isArray(projected.value)) throw new Error("Expected array");
expect(projected.value[0]).toEqual(projected.value[1]);
expect(observed).toHaveLength(1);
expect(observed[0]).toBe(projected.value[0]);
expect(observed[0]).not.toBe(projected.value[1]);
});
+3 -1
View File
@@ -24,7 +24,7 @@ export const unknownDateLimitation = (count: number): string =>
`${String(count)} date value(s) are reported as { "$plist_type": "date", "iso8601": null } because the decoder could not represent them, such as XML dates with years beyond 9999 or negative years, or dates outside the JavaScript date range.`;
/**
* Project a decoded plist into JSON. Data and dates become explicit
* Project a decoded plist into JSON. Data, dates and retained big integers become explicit
* `$plist_type` objects; a non-finite real becomes an explicit unknown
* because the XML decoder reports NaN and ±infinity alike.
*/
@@ -64,6 +64,8 @@ export const projectPlistValue = (
unknownRealCount += 1;
return scalar({ $plist_type: "real", value: null });
}
if (typeof item === "bigint")
return scalar({ $plist_type: "integer", decimal: item.toString() });
if (Array.isArray(item)) {
const result: JsonValue[] = [];
for (let index = 0; index < item.length; index++) {
+8 -1
View File
@@ -4,7 +4,7 @@ import { parse } from "plist";
import {
decodeXmlText,
omitPrototypeKeys,
parseXmlPropertyList,
parseXmlPropertyList as parseWithNumbers,
} from "./propertyListKeys.js";
it("preserves unknown named entities that collide with object prototype properties", () => {
@@ -16,6 +16,13 @@ it("preserves unknown named entities that collide with object prototype properti
);
});
// Existing key/omission controls assert those facts exactly; numeric facts
// have separate source-type and ambiguity controls below.
const parseXmlPropertyList = (text: string) => {
const { numberLiterals: _numbers, ...parsed } = parseWithNumbers(text);
return parsed;
};
const plist = (body: string) =>
`<?xml version="1.0" encoding="UTF-8"?><plist version="1.0">${body}</plist>`;
+31 -117
View File
@@ -1,5 +1,11 @@
import { normalizeLineEndings, type Document, type Node } from "@xmldom/xmldom";
import { parse, parseOpenStep } from "plist";
import { parseOpenStep } from "plist";
import { decodeXmlPropertyList } from "./propertyListXml.js";
import {
createPlistNumberCollector,
type PlistNumberLiterals,
} from "./apple/plistNumbers.js";
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
import { parseXmlDocument } from "./xmlDocument.js";
@@ -13,15 +19,20 @@ export interface PropertyListWithoutPrototypeKeys {
readonly omittedPrototypeKeys: number;
}
/** Value and numeric facts decoded together; OpenStep numeric facts remain unknown. */
export interface ParsedPropertyList extends PropertyListWithoutPrototypeKeys {
readonly numberLiterals: PlistNumberLiterals;
}
/** Limitation for results whose source plist held `__proto__` entries. */
export const omittedPrototypeKeysLimitation = (count: number | null): string =>
count === null
? "Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key."
: `${String(count)} dictionary ${count === 1 ? "entry" : "entries"} keyed __proto__ ${count === 1 ? "was" : "were"} omitted because REA results cannot represent that key.`;
const projectDictionaryKeys = (
/** Remove own `__proto__` entries, such as JSON.parse creates, and count them. */
export const omitPrototypeKeys = (
value: unknown,
projectName: (name: string) => string | undefined,
): PropertyListWithoutPrototypeKeys => {
let omitted = 0;
const strip = (item: unknown): unknown => {
@@ -35,8 +46,7 @@ const projectDictionaryKeys = (
return item;
return Object.fromEntries(
Object.entries(item).flatMap(([name, entry]) => {
const projected = projectName(name);
if (projected !== undefined) return [[projected, strip(entry)]];
if (name !== PROTOTYPE_KEY) return [[name, strip(entry)]];
omitted += 1;
return [];
}),
@@ -46,14 +56,6 @@ const projectDictionaryKeys = (
return { value: stripped, omittedPrototypeKeys: omitted };
};
/** Remove own `__proto__` entries, such as JSON.parse creates, and count them. */
export const omitPrototypeKeys = (
value: unknown,
): PropertyListWithoutPrototypeKeys =>
projectDictionaryKeys(value, (name) =>
name === PROTOTYPE_KEY ? undefined : name,
);
const XML_ENTITIES: Readonly<Record<string, string>> = {
amp: "&",
apos: "'",
@@ -86,14 +88,12 @@ export const decodeXmlText = (text: string): string =>
);
/**
* Decode complete XML character-data keys before the pinned value decoder.
* Decode one validated XML DOM into values and numeric source facts.
* Entries keyed `__proto__` are omitted and counted at the same boundary.
*/
export const parseXmlPropertyList = (
text: string,
): PropertyListWithoutPrototypeKeys => {
export const parseXmlPropertyList = (text: string): ParsedPropertyList => {
// Preserve the pinned decoder's existing OpenStep autodetection, also used
// by app Info.plist resolution. Only XML needs key normalization.
// by app Info.plist resolution.
const first = text.trimStart()[0];
if (first === "{" || first === "(") {
// The pinned decoder assigns a `__proto__` key to the object prototype, so
@@ -101,16 +101,17 @@ export const parseXmlPropertyList = (
// the source instead; the rebuild still drops any prototype it replaced.
const prototypeKeys = scanOpenStep(text);
return {
value: omitPrototypeKeys(parse(text)).value,
value: omitPrototypeKeys(parseOpenStep(text)).value,
numberLiterals: createPlistNumberCollector().result(true),
omittedPrototypeKeys: prototypeKeys,
};
}
// Normalize only the literal line endings the pinned decoder normalizes.
// Preserve character references in the source: serializing a DOM would turn
// e.g. an encoded carriage return into a literal newline on the second parse.
// Decode this DOM directly so character references are never reserialized
// into literal line endings and normalized a second time.
const normalized = normalizeLineEndings(text);
// Validate before plist's recovering XML decoder can turn malformed source
// into observed values. Reuse the validated DOM for depth and key ownership.
// Strict syntax owns well-formedness before value interpretation. Reuse the
// same DOM for depth, keys, values and number facts.
const parsed = parseXmlDocument(normalized, {
doctype: "allow",
lineEndings: "preserve",
@@ -118,11 +119,11 @@ export const parseXmlPropertyList = (
if (!parsed.ok) throw parsed.error;
const document = parsed.value;
assertXmlPlistDepthWithinLimit(document);
const { substituted, names } = normalizeXmlKeys(normalized, document);
return projectDictionaryKeys(parse(substituted), (name) => {
const decoded = names.get(name) ?? name;
return decoded === PROTOTYPE_KEY ? undefined : decoded;
});
const decoded = decodeXmlPropertyList(document);
return {
...omitPrototypeKeys(decoded.value),
numberLiterals: decoded.numberLiterals,
};
};
interface OpenStepContainer {
@@ -286,12 +287,12 @@ const skipOpenStepScalar = (text: string, start: number): number => {
return cursor;
};
/** Elements whose children the pinned plist value decoder visits recursively. */
/** Elements whose children the plist value decoder visits recursively. */
const PLIST_CONTAINER_ELEMENTS = new Set(["plist", "dict", "array"]);
/**
* Reject XML plists nested deeper than the projection supports before any
* recursive value pass runs. `plist` and the prototype-key traversal recurse once
* recursive value pass runs. Value decoding and prototype omission recurse once
* per nesting level, so a deeply nested document exhausts the call stack
* before the projection limit can report the resource bound. The failure
* matches `projectPlistValue` so callers classify it as a limit failure.
@@ -325,90 +326,3 @@ const assertXmlPlistDepthWithinLimit = (document: Document): void => {
pending.push({ node: node.firstChild, depth: depth + 1 });
}
};
/** The validated DOM owns key identities; aliases bypass first-child-only decoding. */
const normalizeXmlKeys = (text: string, document: Document) => {
const keys = Array.from(document.getElementsByTagName("key"))
// The decoder also accepts <key> as a scalar outside dictionaries. Such
// values have no dictionary-name restoration boundary and must not alias.
.filter((key) => key.parentNode?.nodeName === "dict")
.map((key) => {
const first = key.firstChild;
// Nested elements are unsupported key markup. Retain the pinned decoder's
// interpretation instead of treating descendant element text as key data.
const characterDataOnly = !Array.from(key.childNodes).some(
(child) => child.nodeType === child.ELEMENT_NODE,
);
return {
node: first ?? key,
empty: first === null,
name: characterDataOnly
? (key.textContent ?? "")
: first === null
? ""
: String(first.nodeValue),
firstName: first?.nodeValue ?? "",
};
});
const occupied = new Set(
keys.flatMap(({ name, firstName }) => [name, firstName]),
);
const aliases = new Map<string, string>();
const names = new Map<string, string>();
for (const { name, firstName } of keys) {
if ((name !== PROTOTYPE_KEY && name === firstName) || aliases.has(name))
continue;
let alias = `__rea_plist_key_${String(aliases.size)}__`;
while (occupied.has(alias)) alias = `_${alias}`;
occupied.add(alias);
aliases.set(name, alias);
names.set(alias, name);
}
// Alias every occurrence of a corrected name, including plain spellings,
// so the decoder retains its last-value-wins behavior for duplicate keys.
const replacements = keys.filter(({ name }) => aliases.has(name));
if (replacements.length === 0) return { substituted: text, names };
// xmldom uses one-based locators in document order. Scan forward only to
// needed lines instead of retaining an offset for every source newline.
let line = 1;
let lineStart = 0;
const parts: string[] = [];
let cursor = 0;
for (const { node, name, empty } of replacements) {
const nodeLine = node?.lineNumber;
const column = node?.columnNumber;
if (nodeLine === undefined || column === undefined)
throw new TypeError("XML plist key has no source location");
while (line < nodeLine) {
const newline = text.indexOf("\n", lineStart);
if (newline === -1)
throw new TypeError("XML plist key has an invalid source location");
lineStart = newline + 1;
line += 1;
}
const offset = lineStart + column - 1;
const marker = `<![CDATA[${aliases.get(name)}]]>`;
parts.push(text.slice(cursor, offset));
if (empty) {
// Empty keys have no child locator. Replace only their opening tag,
// consuming quoted attributes whole, and expand self-closing spellings.
const opening = /^<(?:[^>"']|"[^"]*"|'[^']*')*>/u.exec(
text.slice(offset),
)?.[0];
if (opening === undefined)
throw new TypeError("XML plist key has an invalid opening tag");
parts.push(
/\/\s*>$/u.test(opening)
? `${opening.replace(/\/\s*>$/u, ">")}${marker}</key>`
: `${opening}${marker}`,
);
cursor = offset + opening.length;
} else {
parts.push(marker);
cursor = offset;
}
}
parts.push(text.slice(cursor));
return { substituted: parts.join(""), names };
};
+70
View File
@@ -0,0 +1,70 @@
import { expect, it } from "vitest";
import { parseXmlPropertyList } from "./propertyListKeys.js";
it.each([
["0x2A", 42],
["0X2a", 42],
["+0x2A", 42],
["-0x2A", -42],
["&#48;x2A", 42],
["\n0x2A\n", 42],
["42", 42],
])(
"decodes exact integer text %s at the shared boundary",
(literal, expected) => {
const parsed = parseXmlPropertyList(
`<plist><integer>${literal}</integer></plist>`,
);
expect(parsed.value).toBe(expected);
expect(parsed.numberLiterals.incomplete).toBe(false);
expect(parsed.numberLiterals.unsafeIntegerLiterals).toBe(0);
},
);
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>",
);
expect(parsed.value).toEqual({ a: 9007199254740992, b: 9007199254740992 });
expect(parsed.numberLiterals.integers.get(9007199254740992)).toEqual(
new Set(["9007199254740993", "9007199254740992"]),
);
expect(parsed.numberLiterals.reals).toEqual(new Set([9007199254740992]));
expect(parsed.numberLiterals.unsafeIntegerLiterals).toBe(2);
expect(parsed.numberLiterals.incomplete).toBe(false);
});
it("does not invent numeric facts from ignored scalar markup", () => {
const parsed = parseXmlPropertyList(
"<plist><array><string>text<integer>9007199254740993</integer></string><dict><key><real>9007199254740992</real></key><integer>7</integer></dict></array></plist>",
);
expect(parsed.value).toEqual(["text", { null: 7 }]);
expect(parsed.numberLiterals.integers.size).toBe(0);
expect(parsed.numberLiterals.reals.size).toBe(0);
expect(parsed.numberLiterals.incomplete).toBe(false);
});
it("retains partial numeric values with explicit incomplete metadata for unsupported markup", () => {
const parsed = parseXmlPropertyList(
"<plist><array><integer>0x2A<!-- unsupported --></integer><real>1<string>ignored</string>.25</real></array></plist>",
);
expect(parsed.value).toEqual([0, 1.25]);
expect(parsed.numberLiterals.incomplete).toBe(true);
});
it("interprets failures in omitted entries before reporting partial evidence", () => {
expect(() =>
parseXmlPropertyList(
"<plist><dict><key>__proto__</key><unsupported/></dict></plist>",
),
).toThrow("Invalid PLIST tag");
});
it("retains native decoder null and nested-envelope behavior", () => {
expect(
parseXmlPropertyList(
"<plist><array><null/><dict><key>value</key><null/></dict><plist><null/></plist></array></plist>",
).value,
).toEqual([{ value: null }, [null]]);
});
+140
View File
@@ -0,0 +1,140 @@
import type { Document, Node } from "@xmldom/xmldom";
import {
createPlistNumberCollector,
parsePlistIntegerLiteral,
} from "./apple/plistNumbers.js";
/** Interpret an already validated, depth-bounded DOM, owning values and number facts together. */
export const decodeXmlPropertyList = (document: Document) => {
const root = document.documentElement;
if (root?.nodeName !== "plist")
throw new TypeError("malformed document. First element should be <plist>");
const numbers = createPlistNumberCollector();
const requireContent = (node: Node): Node => {
if (node.firstChild === null)
throw new TypeError(`Cannot parse "" as ${node.nodeName}`);
return node.firstChild;
};
const textNode = (node: Node) => node.nodeType === node.TEXT_NODE;
const characterNode = (node: Node) =>
textNode(node) || node.nodeType === node.CDATA_SECTION_NODE;
const content = (node: Node, accepts: (child: Node) => boolean) => {
let text = "";
let complete = true;
for (
let child = node.firstChild;
child !== null;
child = child.nextSibling
) {
if (accepts(child)) text += child.nodeValue ?? "";
else complete = false;
}
return { text, complete };
};
const dictionaryKey = (node: Node): string => {
for (let child = node.firstChild; child !== null; child = child.nextSibling)
if (child.nodeType === child.ELEMENT_NODE)
return String(node.firstChild?.nodeValue ?? null);
return content(node, characterNode).text;
};
const members = function* (node: Node) {
for (let child = node.firstChild; child !== null; child = child.nextSibling)
if (
child.nodeType !== child.TEXT_NODE &&
child.nodeType !== child.CDATA_SECTION_NODE &&
child.nodeType !== child.COMMENT_NODE
)
yield child;
};
const decode = (node: Node): unknown => {
switch (node.nodeName) {
case "plist":
return Array.from(members(node), decode);
case "array": {
const values: unknown[] = [];
for (const member of members(node)) {
const value = decode(member);
if (value !== null) values.push(value);
}
return values;
}
case "dict": {
const entries: [string, unknown][] = [];
let key: string | undefined;
for (const child of members(node)) {
if (key === undefined) {
if (child.nodeName !== "key")
throw new TypeError("Missing key while parsing <dict/>");
key = dictionaryKey(child);
} else {
if (child.nodeName === "key")
throw new TypeError(
"Unexpected <key> while parsing <dict/>. Keys and values must alternate.",
);
entries.push([key, decode(child)]);
key = undefined;
}
}
if (key !== undefined) entries.push([key, ""]);
// Define own properties safely, including __proto__; the caller owns omission accounting.
return Object.fromEntries(entries);
}
case "key": {
// The pinned decoder also accepted key elements as scalars. Keep their
// first-child interpretation separate from dictionary key identities.
const value = node.firstChild === null ? "" : node.firstChild.nodeValue;
if (value === "__proto__")
throw new TypeError("__proto__ is not a scalar plist key");
return value;
}
case "string":
return content(node, characterNode).text;
case "integer": {
const first = requireContent(node);
const literal = content(node, textNode);
const exact = literal.complete
? parsePlistIntegerLiteral(literal.text)
: undefined;
if (exact === undefined) {
numbers.markIncomplete();
return Number.parseInt(first.nodeValue ?? "", 10);
}
numbers.integer(exact);
const decoded = Number(exact);
// Keep a finite integer's identity when it cannot fit even a Number
// approximation; otherwise it would be mislabeled as a non-finite real.
return Number.isFinite(decoded) ? decoded : exact;
}
case "real": {
requireContent(node);
const literal = content(node, characterNode);
const value = Number.parseFloat(literal.text);
if (literal.complete) numbers.real(value);
else numbers.markIncomplete();
return value;
}
case "data": {
const binary = atob(content(node, textNode).text.replace(/\s+/gu, ""));
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
case "date": {
const value = requireContent(node).nodeValue;
return new Date(value === null ? 0 : value);
}
case "null":
return null;
case "true":
return true;
case "false":
return false;
default:
throw new TypeError(`Invalid PLIST tag ${node.nodeName}`);
}
};
const values = Array.from(members(root), decode);
return {
value: values.length === 1 ? values[0] : values,
numberLiterals: numbers.result(),
};
};
+18
View File
@@ -20,6 +20,24 @@ it("retains complete segmented XML keys in native plist fallback evidence", () =
);
});
it("does not relabel integral CDATA reals as integers in native JSON or XML evidence", () => {
const xml =
"<plist><dict><key>integer</key><integer>9007199254740992</integer><key>real</key><real>9007199254<![CDATA[740992]]></real></dict></plist>";
for (const parsed of [
parsePlistJson('{"integer":9007199254740992,"real":9007199254740992}', xml),
parsePlistXml(xml),
]) {
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual({
integer: 9007199254740992,
real: 9007199254740992,
});
expect(parsed.value.limitations).toContainEqual(
expect.stringContaining("2 number(s)"),
);
}
});
it("keeps exact decimal text for JSON integers that XML shows are integers", () => {
const parsed = parsePlistJson(
'{"Max":18446744073709551615,"Min":-9223372036854775808,"Near":9007199254740993,"Edge":9007199254740992,"Safe":9007199254740991,"Real":1e21,"Ratio":0.5}',
+21 -34
View File
@@ -14,6 +14,11 @@ import {
} from "../../domain/propertyListKeys.js";
import { err, ok, type Result } from "../../domain/result.js";
import {
incompletePlistNumberLimitation,
type PlistNumberLiterals,
} from "../../domain/apple/plistNumbers.js";
const plistObject = z.record(z.string(), z.unknown());
/** Stable bundle metadata projected from one plist value. */
@@ -87,14 +92,15 @@ export const parsePlistJson = (
output: string,
xmlConversion?: string,
): Result<ParsedPlist, AnalysisOutputError> => {
const literals =
xmlConversion === undefined ? undefined : xmlNumberLiterals(xmlConversion);
let literals: PlistNumberLiterals | undefined;
let exactIntegerCount = 0;
let ambiguousNumberCount = 0;
const classify = (item: number, digits: string): unknown => {
const exact = BigInt(digits).toString();
const integer = literals?.integers.get(item)?.has(exact) === true;
const real = literals?.reals.has(item) === true;
const integer =
literals?.incomplete === false &&
literals.integers.get(item)?.has(exact) === true;
const real = literals?.incomplete === false && literals.reals.has(item);
if (integer && !real) {
exactIntegerCount += 1;
return { $plist_type: "integer", decimal: digits };
@@ -106,6 +112,10 @@ export const parsePlistJson = (
};
let decoded: ReturnType<typeof omitPrototypeKeys>;
try {
literals =
xmlConversion === undefined
? undefined
: parseXmlPropertyList(xmlConversion).numberLiterals;
decoded = omitPrototypeKeys(decodePlistJson(output, classify));
} catch (cause: unknown) {
return err(
@@ -121,6 +131,9 @@ export const parsePlistJson = (
value,
bundle: projectPlistBundle(value),
limitations: [
...(literals?.incomplete === true
? [incompletePlistNumberLimitation]
: []),
...numberLimitations(exactIntegerCount, ambiguousNumberCount),
...prototypeKeyLimitations(decoded.omittedPrototypeKeys),
],
@@ -136,9 +149,11 @@ export const parsePlistXml = (
): Result<ParsedPlist, AnalysisOutputError> => {
let projected: ProjectedPlistValue;
let omittedPrototypeKeys: number;
let literals: PlistNumberLiterals;
try {
const decoded = parseXmlPropertyList(output);
omittedPrototypeKeys = decoded.omittedPrototypeKeys;
literals = decoded.numberLiterals;
projected = projectPlistValue(decoded.value);
} catch (cause: unknown) {
return err(
@@ -151,7 +166,6 @@ export const parsePlistXml = (
}
// The XML decoder turns <integer> and <real> into numbers before REA sees
// them, rounding integers beyond the exact range.
const literals = xmlNumberLiterals(output);
let exactIntegerCount = 0;
let ambiguousNumberCount = 0;
const value = mapJsonNumbers(projected.value, (item) => {
@@ -159,6 +173,7 @@ export const parsePlistXml = (
if (decimals === undefined) return item;
const [decimal] = decimals;
if (
literals.incomplete ||
decimals.size !== 1 ||
decimal === undefined ||
literals.reals.has(item)
@@ -182,41 +197,13 @@ export const parsePlistXml = (
...(projected.unknownDateCount === 0
? []
: [unknownDateLimitation(projected.unknownDateCount)]),
...(literals.incomplete ? [incompletePlistNumberLimitation] : []),
...numberLimitations(exactIntegerCount, ambiguousNumberCount),
...prototypeKeyLimitations(omittedPrototypeKeys),
],
});
};
/** `<integer>` and `<real>` element literals in plutil's XML conversion. */
interface XmlNumberLiterals {
/** Exact integer literals beyond the safe range, keyed by decoded number. */
readonly integers: ReadonlyMap<number, ReadonlySet<string>>;
readonly reals: ReadonlySet<number>;
}
// plutil escapes "<" in keys and strings, so these element literals can be
// read from the XML text.
const xmlNumberLiterals = (xml: string): XmlNumberLiterals => {
const integers = new Map<number, Set<string>>();
for (const [, decimal = ""] of xml.matchAll(
/<integer>\s*([+-]?\d+)\s*<\/integer>/gu,
)) {
const rounded = Number(decimal);
if (Number.isSafeInteger(rounded)) continue;
integers.set(
rounded,
(integers.get(rounded) ?? new Set()).add(BigInt(decimal).toString()),
);
}
const reals = new Set(
[...xml.matchAll(/<real>([^<]*)<\/real>/gu)].map(([, text = ""]) =>
Number.parseFloat(text),
),
);
return { integers, reals };
};
const prototypeKeyLimitations = (count: number): string[] =>
count === 0 ? [] : [omittedPrototypeKeysLimitation(count)];
@@ -17,6 +17,8 @@ const scenarios = [
"integer collision",
"integer-real collision",
"real",
"CDATA real",
"hexadecimal",
"mixed XML",
"segmented keys",
] as const;
@@ -66,9 +68,13 @@ cliTest.for(scenarios)(
"<string>__INTEGER__</string>",
scenario === "real"
? "<real>9007199254740992</real>"
: scenario === "mixed XML"
? "<integer>9007199254740992<!-- split -->3</integer>"
: "<integer>9007199254740993</integer>",
: scenario === "CDATA real"
? "<real>9007199254<![CDATA[740992]]></real>"
: scenario === "hexadecimal"
? "<integer>0x20000000000001</integer>"
: scenario === "mixed XML"
? "<integer>9007199254740992<!-- split -->3</integer>"
: "<integer>9007199254740993</integer>",
)
.replace(
"<string>__OTHER__</string>",
@@ -122,6 +128,7 @@ cliTest.for(scenarios)(
large:
scenario === "XML" ||
scenario === "binary" ||
scenario === "hexadecimal" ||
scenario === "segmented keys"
? { $plist_type: "integer", decimal: "9007199254740993" }
: 9007199254740992,
@@ -142,12 +149,13 @@ cliTest.for(scenarios)(
if (
scenario === "XML" ||
scenario === "binary" ||
scenario === "hexadecimal" ||
scenario === "segmented keys"
)
expect(result.limitations).toContainEqual(
expect.stringContaining("1 unambiguous integer value"),
);
else if (scenario !== "real")
else if (scenario !== "real" && scenario !== "CDATA real")
expect(result.limitations).toContainEqual(
expect.stringContaining("integer precision loss remains possible"),
);
@@ -0,0 +1,113 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { build } from "plist";
import { expect } from "vitest";
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest.for([
{ name: "plain real", element: "<real>1.25</real>", expected: 1.25 },
{
name: "mixed real",
element: "<real>1<![CDATA[.25]]></real>",
expected: 1.25,
},
{
name: "CDATA real",
element: "<real><![CDATA[1.25]]></real>",
expected: 1.25,
},
{ name: "hexadecimal", element: "<integer>0x2A</integer>", expected: 42 },
{
name: "unsafe hexadecimal",
element: "<integer>0x20000000000001</integer>",
expected: { $plist_type: "integer", decimal: "9007199254740993" },
},
{
name: "integer beyond finite Number range",
element: `<integer>1${"0".repeat(400)}</integer>`,
expected: { $plist_type: "integer", decimal: `1${"0".repeat(400)}` },
},
])(
"preserves $name XML numbers through CLI and MCP",
async (scenario, { cli }) => {
const root = await createTestTempDirectory("rea-keyed-xml-numbers-");
const bytes = Buffer.from(
build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", "__VALUE__"],
$top: { root: { CF$UID: 1 } },
}).replace("<string>__VALUE__</string>", scenario.element),
);
const path = join(root, "archive.plist");
await writeFile(path, bytes);
const { call } = await connectLocalToolsMcp({
HOME: root,
USERPROFILE: root,
});
const output = await cli.run({
arguments: [
"inspect-keyed-archive",
path,
"--root",
"root",
"--offset",
"1",
"--limit",
"1",
"--json",
],
environment: { HOME: root, USERPROFILE: root, REA_LOG_LEVEL: "silent" },
});
expect(output.exitCode, output.stderr).toBe(0);
const result = keyedArchiveResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
expect((await call("open_binary", { path })).isError).not.toBe(true);
const mcp = await call("inspect_keyed_archive", {
root: "root",
offset: 1,
limit: 1,
});
expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true);
expect(
keyedArchiveResultSchema.parse(
parseEvidence(mcp.structuredContent).normalized_result,
),
).toEqual(result);
expect(result.archive_sha256).toBe(
createHash("sha256").update(bytes).digest("hex"),
);
expect(result.objects).toEqual([
{
id: 1,
kind: "scalar",
class_id: null,
class_name: null,
status: "observed",
value: scenario.expected,
},
]);
expect(result.references).toEqual([
expect.objectContaining({
source: null,
path: ["root"],
target: 1,
status: "resolved",
}),
]);
expect(
result.limitations.some((note) =>
/non-finite|completely observed/u.test(note),
),
).toBe(false);
expect(await readFile(path)).toEqual(bytes);
},
);
@@ -9,7 +9,12 @@ import { parseEvidence } from "../../../src/domain/evidence.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const cases = ["unsafe-integer", "integral-real"] as const;
const cases = [
"unsafe-integer",
"integral-real",
"hexadecimal-integer",
"unsafe-hexadecimal-integer",
] as const;
const fixture = async (kind: (typeof cases)[number]) => {
const root = await createTestTempDirectory("rea-integer-scalar-");
const xml = build({
@@ -21,7 +26,11 @@ const fixture = async (kind: (typeof cases)[number]) => {
"<string>__VALUE__</string>",
kind === "unsafe-integer"
? "<integer>9007199254740993</integer>"
: "<real>9007199254740992</real>",
: kind === "hexadecimal-integer"
? "<integer>0x2A</integer>"
: kind === "unsafe-hexadecimal-integer"
? "<integer>0x20000000000001</integer>"
: "<real>9007199254740992</real>",
);
const bytes = Buffer.from(xml);
const path = join(root, "archive.plist");
@@ -65,8 +74,22 @@ const verify = (
expect(result.truncated).toBe(false);
const value = result.objects.find(({ id }) => id === 1)?.value;
expect(value).toBeDefined();
const precisionNote = result.limitations.some(
(note) =>
/integer|number/iu.test(note) &&
/precision|exact range|rounded|completely observed/iu.test(note),
);
if (kind === "integral-real") expect(value).toBe(9007199254740992);
else {
else if (kind === "hexadecimal-integer") {
// Apple's parser reads 0x2A as 42; the literal is classified, not unknown.
expect(value).toBe(42);
expect(precisionNote).toBe(false);
} else if (kind === "unsafe-hexadecimal-integer") {
expect(value).toEqual({
$plist_type: "integer",
decimal: "9007199254740993",
});
} else {
const exact =
typeof value === "object" &&
value !== null &&
+17 -2
View File
@@ -14,8 +14,6 @@ const ROUNDED = 9007199254740992;
/** Accepted decoder representations whose supplemental metadata needs care. */
export const mixedArchiveNumberElements = [
"<real>9007199254740992<![CDATA[0]]></real>",
"<real><![CDATA[1]]>9007199254740992</real>",
"<integer>9007199254740992<!-- split -->3</integer>",
"<integer>9007199254740992<![CDATA[3]]></integer>",
"<integer><![CDATA[9007199254740992]]>3</integer>",
@@ -27,6 +25,23 @@ export const mixedArchiveNumberElements = [
/** Ordinary dictionaries and malformed numeric references at the same boundary. */
export const archiveNumberMetadataCases = [
...[
{
element: "<real>9007199254740992<![CDATA[0]]></real>",
value: 90071992547409920,
},
{
element: "<real><![CDATA[1]]>9007199254740992</real>",
value: Number("19007199254740992"),
},
].map(({ element, value }) => ({
name: element,
source: { first: "__INTEGER__", other: "__OTHER__" },
element,
expected: { first: EXACT_INTEGER, other: value },
incomplete: false,
malformedReference: false,
})),
...mixedArchiveNumberElements.map((element) => ({
name: element,
source: { first: "__INTEGER__", other: "__OTHER__" },