diff --git a/docs/native-investigation.md b/docs/native-investigation.md
index b9b010ed8e..432f68efda 100644
--- a/docs/native-investigation.md
+++ b/docs/native-investigation.md
@@ -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 `` text, including an optional sign, retains its value;
+`` 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": "" }` when
that source value can be associated unambiguously. Integral real values remain
diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts
index 86e8941c96..d48fba88d9 100644
--- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts
+++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts
@@ -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,
};
};
diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts
index e2340f3ad2..b7b0d109b8 100644
--- a/src/artifacts/apple/KeyedArchiveReader.ts
+++ b/src/artifacts/apple/KeyedArchiveReader.ts
@@ -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(
diff --git a/src/artifacts/apple/PlistNumberLiterals.ts b/src/artifacts/apple/PlistNumberLiterals.ts
index b5293a0e0a..12b55ecb9b 100644
--- a/src/artifacts/apple/PlistNumberLiterals.ts
+++ b/src/artifacts/apple/PlistNumberLiterals.ts
@@ -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>();
- const reals = new Set();
- 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 =
- /||<(?:[^>"']|"[^"]*"|'[^']*')*>/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("1ignored.25",
+ );
+ 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(
+ "__proto__",
+ ),
+ ).toThrow("Invalid PLIST tag");
+});
+
+it("retains native decoder null and nested-envelope behavior", () => {
+ expect(
+ parseXmlPropertyList(
+ "value",
+ ).value,
+ ).toEqual([{ value: null }, [null]]);
+});
diff --git a/src/domain/propertyListXml.ts b/src/domain/propertyListXml.ts
new file mode 100644
index 0000000000..696f917ab4
--- /dev/null
+++ b/src/domain/propertyListXml.ts
@@ -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 ");
+ 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 ");
+ key = dictionaryKey(child);
+ } else {
+ if (child.nodeName === "key")
+ throw new TypeError(
+ "Unexpected while parsing . 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(),
+ };
+};
diff --git a/src/native/parsers/plist.test.ts b/src/native/parsers/plist.test.ts
index 8d6be612de..1ad64bc630 100644
--- a/src/native/parsers/plist.test.ts
+++ b/src/native/parsers/plist.test.ts
@@ -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 =
+ "integer9007199254740992real9007199254";
+ 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}',
diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts
index a3b6c4fa5e..5fb0fecab2 100644
--- a/src/native/parsers/plist.ts
+++ b/src/native/parsers/plist.ts
@@ -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 => {
- 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;
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 => {
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 and 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),
],
});
};
-/** `` and `` element literals in plutil's XML conversion. */
-interface XmlNumberLiterals {
- /** Exact integer literals beyond the safe range, keyed by decoded number. */
- readonly integers: ReadonlyMap>;
- readonly reals: ReadonlySet;
-}
-
-// 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>();
- for (const [, decimal = ""] of xml.matchAll(
- /\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>/gu)].map(([, text = ""]) =>
- Number.parseFloat(text),
- ),
- );
- return { integers, reals };
-};
-
const prototypeKeyLimitations = (count: number): string[] =>
count === 0 ? [] : [omittedPrototypeKeysLimitation(count)];
diff --git a/tests/acceptance/applications/interfaceBuilderPlistNumbers.test.ts b/tests/acceptance/applications/interfaceBuilderPlistNumbers.test.ts
index 3c7049e3b9..b8e8395975 100644
--- a/tests/acceptance/applications/interfaceBuilderPlistNumbers.test.ts
+++ b/tests/acceptance/applications/interfaceBuilderPlistNumbers.test.ts
@@ -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)(
"__INTEGER__",
scenario === "real"
? "9007199254740992"
- : scenario === "mixed XML"
- ? "90071992547409923"
- : "9007199254740993",
+ : scenario === "CDATA real"
+ ? "9007199254"
+ : scenario === "hexadecimal"
+ ? "0x20000000000001"
+ : scenario === "mixed XML"
+ ? "90071992547409923"
+ : "9007199254740993",
)
.replace(
"__OTHER__",
@@ -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"),
);
diff --git a/tests/acceptance/applications/keyedArchiveXmlNumbers.test.ts b/tests/acceptance/applications/keyedArchiveXmlNumbers.test.ts
new file mode 100644
index 0000000000..c53ffa252c
--- /dev/null
+++ b/tests/acceptance/applications/keyedArchiveXmlNumbers.test.ts
@@ -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: "1.25", expected: 1.25 },
+ {
+ name: "mixed real",
+ element: "1",
+ expected: 1.25,
+ },
+ {
+ name: "CDATA real",
+ element: "",
+ expected: 1.25,
+ },
+ { name: "hexadecimal", element: "0x2A", expected: 42 },
+ {
+ name: "unsafe hexadecimal",
+ element: "0x20000000000001",
+ expected: { $plist_type: "integer", decimal: "9007199254740993" },
+ },
+ {
+ name: "integer beyond finite Number range",
+ element: `1${"0".repeat(400)}`,
+ 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("__VALUE__", 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);
+ },
+);
diff --git a/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts b/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts
index 47756552ff..1f1ba969ad 100644
--- a/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts
+++ b/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts
@@ -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]) => {
"__VALUE__",
kind === "unsafe-integer"
? "9007199254740993"
- : "9007199254740992",
+ : kind === "hexadecimal-integer"
+ ? "0x2A"
+ : kind === "unsafe-hexadecimal-integer"
+ ? "0x20000000000001"
+ : "9007199254740992",
);
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 &&
diff --git a/tests/fixtures/keyedArchiveNumberMetadata.ts b/tests/fixtures/keyedArchiveNumberMetadata.ts
index f63b86fb0b..9369692c93 100644
--- a/tests/fixtures/keyedArchiveNumberMetadata.ts
+++ b/tests/fixtures/keyedArchiveNumberMetadata.ts
@@ -14,8 +14,6 @@ const ROUNDED = 9007199254740992;
/** Accepted decoder representations whose supplemental metadata needs care. */
export const mixedArchiveNumberElements = [
- "9007199254740992",
- "9007199254740992",
"90071992547409923",
"9007199254740992",
"3",
@@ -27,6 +25,23 @@ export const mixedArchiveNumberElements = [
/** Ordinary dictionaries and malformed numeric references at the same boundary. */
export const archiveNumberMetadataCases = [
+ ...[
+ {
+ element: "9007199254740992",
+ value: 90071992547409920,
+ },
+ {
+ element: "9007199254740992",
+ 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__" },