From 7aa4b98c9829148b81211aff4e0413cb4ff2ff92 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:40:55 +0800 Subject: [PATCH] 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 --- docs/native-investigation.md | 4 + .../apple/InterfaceBuilderAnalysis.ts | 8 +- src/artifacts/apple/KeyedArchiveReader.ts | 8 +- src/artifacts/apple/PlistNumberLiterals.ts | 131 +--------------- src/domain/apple/plistNumbers.ts | 56 ++++++- src/domain/apple/plistValue.test.ts | 17 ++ src/domain/apple/plistValue.ts | 4 +- src/domain/propertyListKeys.test.ts | 9 +- src/domain/propertyListKeys.ts | 148 ++++-------------- src/domain/propertyListXml.test.ts | 70 +++++++++ src/domain/propertyListXml.ts | 140 +++++++++++++++++ src/native/parsers/plist.test.ts | 18 +++ src/native/parsers/plist.ts | 55 +++---- .../interfaceBuilderPlistNumbers.test.ts | 16 +- .../keyedArchiveXmlNumbers.test.ts | 113 +++++++++++++ .../keyedArchiveScalarIdentity.test.ts | 29 +++- tests/fixtures/keyedArchiveNumberMetadata.ts | 19 ++- 17 files changed, 542 insertions(+), 303 deletions(-) create mode 100644 src/domain/propertyListXml.test.ts create mode 100644 src/domain/propertyListXml.ts create mode 100644 tests/acceptance/applications/keyedArchiveXmlNumbers.test.ts 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__" },