From 4034883e9860a97edee03260fab59f9229debfa6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:13:13 +0800 Subject: [PATCH] fix(artifacts): preserve keyed archive integer precision Integrate the numeric precision correction from #1135 with complete supplemental metadata handling. Keep decoded numbers with an explicit limitation for mixed XML content or unclassified integer text, rather than assigning another literal's exact decimal. Project nonnumeric UID and CF$UID dictionary values recursively while preserving numeric references, graph identity and original byte digests. Add filesystem, built CLI and MCP regressions for the review findings, adjacent XML representations and numeric reference controls. Retain the original native Foundation qualification fixtures and verifier. Verification: 84 focused tests; npm run check; npm run docs:check; npm run verify:test-discovery; npm run verify:keyed-archive (real Foundation, CLI and stdio MCP). No real Hopper or Ghidra verification was needed or run. Co-authored-by: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Signed-off-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/native-investigation.md | 22 +++ scripts/verify/apple/keyed-archive.mjs | 22 ++- src/artifacts/apple/KeyedArchiveReader.ts | 28 ++- src/artifacts/apple/PlistNumberLiterals.ts | 174 ++++++++++++++++++ src/domain/apple/plistNumbers.ts | 91 +++++++++ src/domain/propertyListKeys.ts | 2 +- .../keyedArchiveIntegerAmbiguity.test.ts | 163 ++++++++++++++++ .../keyedArchiveNativeIntegers.test.ts | 61 ++++++ .../keyedArchiveNumberMetadata.test.ts | 39 ++++ .../keyedArchiveScalarIdentity.test.ts | 115 ++++++++++++ .../mcp/keyedArchiveNumberMetadataMcp.test.ts | 44 +++++ .../native/keyed-archive-integers.swift | 134 ++++++++++++++ tests/fixtures/keyedArchiveIntegers.ts | 143 ++++++++++++++ tests/fixtures/keyedArchiveNumberMetadata.ts | 154 ++++++++++++++++ 14 files changed, 1189 insertions(+), 3 deletions(-) create mode 100644 src/artifacts/apple/PlistNumberLiterals.ts create mode 100644 src/domain/apple/plistNumbers.ts create mode 100644 tests/boundary/filesystem/keyedArchiveIntegerAmbiguity.test.ts create mode 100644 tests/boundary/filesystem/keyedArchiveNativeIntegers.test.ts create mode 100644 tests/boundary/filesystem/keyedArchiveNumberMetadata.test.ts create mode 100644 tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts create mode 100644 tests/boundary/mcp/keyedArchiveNumberMetadataMcp.test.ts create mode 100644 tests/conformance/native/keyed-archive-integers.swift create mode 100644 tests/fixtures/keyedArchiveIntegers.ts create mode 100644 tests/fixtures/keyedArchiveNumberMetadata.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 5d97e7c6f..8ad7aadbd 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -383,3 +383,25 @@ A keyed-archive hierarchy UID without an object-table entry marks the hierarchy as partial while preserving decoded objects and known links. UID 0 remains archived nil and does not count as a missing reference. Archive and evidence digests identify the original serialized bytes. + +## Keyed archive integer precision + +The archive reader observes XML number element types and binary number markers. +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 +numbers. Colliding integer/real values, unclassified numbers, and malformed UID +markers remain decoded with an explicit precision limitation. The limitation +also counts observed unsafe integer literals, including observations outside the +selected object page. Incomplete supplemental metadata produces an explicit +precision note without rejecting an archive accepted by the byte decoder. +Mixed XML number content (comments, CDATA, processing instructions or child +elements) and integer text the supplemental reader cannot classify disable exact +association and retain decoded numbers with that precision note. Nonnumeric +`UID` and `CF$UID` dictionary values remain ordinary data and are projected +recursively; numeric reference markers retain their original representation. + +Value projection preserves serialized node kinds, IDs, references and pagination. +Exact and ambiguous value counts describe emitted root/object value instances; +a root and an object may contain the same value. Archive digests identify the +original bytes. diff --git a/scripts/verify/apple/keyed-archive.mjs b/scripts/verify/apple/keyed-archive.mjs index 4a162e72e..d8bc284ba 100644 --- a/scripts/verify/apple/keyed-archive.mjs +++ b/scripts/verify/apple/keyed-archive.mjs @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { execFile, spawnSync } from "node:child_process"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -151,6 +151,26 @@ try { [{ source: null, path: ["UID"], target: 1, status: "resolved" }], ); } + const numericTests = spawnSync( + "npm", + [ + "run", + "test:focused", + "--", + "tests/boundary/filesystem/keyedArchiveNativeIntegers.test.ts", + ], + { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + stdio: "inherit", + timeout: 240_000, + }, + ); + if (numericTests.error) throw numericTests.error; + assert.equal( + numericTests.status, + 0, + "Native Foundation integer cases failed", + ); report = { ok: true, mocked: false, diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 54401c4a1..d7d4dc99f 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -4,6 +4,11 @@ import { z } from "zod"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { projectPlistValue } from "../../domain/apple/plistValue.js"; +import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js"; +import { + binaryPlistNumberLiterals, + xmlPlistNumberLiterals, +} from "./PlistNumberLiterals.js"; import { omittedPrototypeKeysLimitation, parseXmlPropertyList, @@ -32,15 +37,36 @@ export const decodeKeyedArchiveBytes = ( "Selected file is a compiled NIBArchive, not a Foundation plist archive; decode it with decode_interface_builder", ); const binary = bytes.subarray(0, 8).toString("ascii") === "bplist00"; + const xmlText = binary ? undefined : decodeXmlPlistText(bytes); const parsed = binary ? { value: parseBinary(bytes), omittedPrototypeKeys: 0 } - : parseXmlPropertyList(decodeXmlPlistText(bytes)); + : parseXmlPropertyList(xmlText ?? ""); const graph = projectKeyedArchive(normalizePlist(parsed.value), selection); + const numbers = createPlistNumberProjection( + binary + ? binaryPlistNumberLiterals(bytes) + : xmlPlistNumberLiterals(xmlText ?? ""), + ); + // 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( + Object.entries(graph.roots).map(([name, value]) => [ + name, + numbers.project(value), + ]), + ); + const objects = graph.objects.map((object) => ({ + ...object, + value: numbers.project(object.value), + })); return { archive_format: binary ? ("binary-plist" as const) : ("xml-plist" as const), ...graph, + roots, + objects, limitations: [ ...graph.limitations, + ...numbers.limitations(), ...(parsed.omittedPrototypeKeys === 0 ? [] : [omittedPrototypeKeysLimitation(parsed.omittedPrototypeKeys)]), diff --git a/src/artifacts/apple/PlistNumberLiterals.ts b/src/artifacts/apple/PlistNumberLiterals.ts new file mode 100644 index 000000000..b491a7274 --- /dev/null +++ b/src/artifacts/apple/PlistNumberLiterals.ts @@ -0,0 +1,174 @@ +import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js"; +import { decodeXmlText } from "../../domain/propertyListKeys.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("", + ); + bytes = Buffer.from(xml); + } + const path = join(root, "archive.plist"); + await writeFile(path, bytes); + return { + root, + path, + digest: createHash("sha256").update(bytes).digest("hex"), + }; +}; +const verify = ( + result: ReturnType, + scenario: (typeof scenarios)[number], + digest: string, +) => { + expect(result.archive_sha256).toBe(digest); + expect(result.objects).toHaveLength(2); + expect(result.references).toContainEqual( + expect.objectContaining({ source: null, target: 1, status: "resolved" }), + ); + const value = result.objects.find(({ id }) => id === 1)?.value; + expect(value).toBeDefined(); + expect(value).not.toBeNull(); + expect(Array.isArray(value)).toBe(false); + expect(typeof value).toBe("object"); + const fields = value as Record; + const precisionNote = result.limitations.some( + (note) => + /integer|number/iu.test(note) && + /precision|exact range|rounded/iu.test(note), + ); + if (scenario === "integer-collision") { + // Neither colliding decoded slot may be assigned one arbitrary exact literal. + expect(fields.first).toBe(9007199254740992); + expect(fields.second).toBe(9007199254740992); + expect(precisionNote).toBe(true); + } else if (scenario === "integer-real-collision") { + expect(fields.first).toBe(9007199254740992); + expect(fields.real).toBe(9007199254740992); + expect(precisionNote).toBe(true); + } else if (scenario === "opaque-xml") { + expect(fields.text).toBe("9007199254740993"); + expect(fields.real).toBe(9007199254740996); + expect(fields.first).toBeDefined(); + const exact = + typeof fields.first === "object" && + fields.first !== null && + JSON.stringify(fields.first) === + JSON.stringify({ $plist_type: "integer", decimal: "9007199254740993" }); + expect(exact || precisionNote).toBe(true); + // If a literal count is reported, opaque content must not inflate it. + const countNote = result.limitations.find((note) => + /integer literal\(s\)/u.test(note), + ); + if (countNote !== undefined) + expect(countNote).toMatch(/^1 observed integer literal/u); + } else { + expect(fields.real).toBe(9007199254740992); + } +}; +it.each(scenarios)( + "filesystem retains numeric meaning for %s", + async (scenario) => { + const item = await fixture(scenario); + const result = await inspectBundleKeyedArchive({ + bundlePath: item.root, + targetSha256: item.digest, + parameters: { path: "archive.plist", limit: 100 }, + }); + verify(result, scenario, item.digest); + }, +); +cliTest.for(scenarios)( + "built CLI retains numeric meaning for %s", + async (scenario, { cli }) => { + const item = await fixture(scenario); + const output = await cli.run({ + arguments: ["inspect-keyed-archive", item.path, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode).toBe(0); + verify( + keyedArchiveResultSchema.parse( + parseEvidence(output.json).normalized_result, + ), + scenario, + item.digest, + ); + }, +); diff --git a/tests/boundary/filesystem/keyedArchiveNativeIntegers.test.ts b/tests/boundary/filesystem/keyedArchiveNativeIntegers.test.ts new file mode 100644 index 000000000..9ba2583e7 --- /dev/null +++ b/tests/boundary/filesystem/keyedArchiveNativeIntegers.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js"; +import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { + keyedArchiveIntegerFormats, + keyedArchiveIntegerCases, + keyedArchiveIntegerFixture, + expectKeyedArchiveIntegerCase, +} from "../../fixtures/keyedArchiveIntegers.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const cases = keyedArchiveIntegerFormats.flatMap((format) => + keyedArchiveIntegerCases.map((selectedCase) => ({ format, selectedCase })), +); +// Selected by the existing required native keyed-archive verifier, not Linux skips. +describe.skipIf(process.platform !== "darwin")( + "Foundation archive integer precision", + () => { + it.each(cases)( + "filesystem preserves $format $selectedCase numeric meaning", + async ({ format, selectedCase }) => { + const fixture = await keyedArchiveIntegerFixture(format); + const result = await inspectBundleKeyedArchive({ + bundlePath: fixture.root, + targetSha256: fixture.digest, + parameters: { path: fixture.selected.filename, limit: 100 }, + }); + expectKeyedArchiveIntegerCase( + result, + selectedCase, + fixture.selected, + fixture.digest, + ); + }, + ); + cliTest.for(cases)( + "built CLI preserves $format $selectedCase numeric meaning", + async ({ format, selectedCase }, { cli }) => { + const fixture = await keyedArchiveIntegerFixture(format); + const output = await cli.run({ + arguments: ["inspect-keyed-archive", fixture.archivePath, "--json"], + environment: { + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "auto", + }, + }); + expect(output.exitCode).toBe(0); + const result = keyedArchiveResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expectKeyedArchiveIntegerCase( + result, + selectedCase, + fixture.selected, + fixture.digest, + ); + }, + ); + }, +); diff --git a/tests/boundary/filesystem/keyedArchiveNumberMetadata.test.ts b/tests/boundary/filesystem/keyedArchiveNumberMetadata.test.ts new file mode 100644 index 000000000..14034f8b3 --- /dev/null +++ b/tests/boundary/filesystem/keyedArchiveNumberMetadata.test.ts @@ -0,0 +1,39 @@ +import { it } from "vitest"; + +import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { + archiveNumberMetadataCases, + expectKeyedArchiveNumberMetadata, + keyedArchiveNumberMetadataFixture, +} from "../../fixtures/keyedArchiveNumberMetadata.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +it.each(archiveNumberMetadataCases)( + "filesystem preserves numeric meaning for $name", + async (item) => { + const fixture = await keyedArchiveNumberMetadataFixture(item); + const result = await inspectBundleKeyedArchive({ + bundlePath: fixture.root, + targetSha256: fixture.digest, + parameters: { path: "archive.plist" }, + }); + expectKeyedArchiveNumberMetadata(result, item, fixture.digest); + }, +); + +cliTest.for(archiveNumberMetadataCases)( + "built CLI preserves numeric meaning for $name", + async (item, { cli }) => { + const fixture = await keyedArchiveNumberMetadataFixture(item); + const output = await cli.run({ + arguments: ["inspect-keyed-archive", fixture.path, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expectKeyedArchiveNumberMetadata( + parseEvidence(output.json).normalized_result, + item, + fixture.digest, + ); + }, +); diff --git a/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts b/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts new file mode 100644 index 000000000..47756552f --- /dev/null +++ b/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts @@ -0,0 +1,115 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { build } from "plist"; +import { expect, it } from "vitest"; +import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js"; +import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; +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 fixture = async (kind: (typeof cases)[number]) => { + const root = await createTestTempDirectory("rea-integer-scalar-"); + const xml = build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "__VALUE__"], + $top: { root: { CF$UID: 1 } }, + }).replace( + "__VALUE__", + kind === "unsafe-integer" + ? "9007199254740993" + : "9007199254740992", + ); + const bytes = Buffer.from(xml); + const path = join(root, "archive.plist"); + await writeFile(path, bytes); + return { + root, + path, + digest: createHash("sha256").update(bytes).digest("hex"), + }; +}; +const verify = ( + result: ReturnType, + kind: (typeof cases)[number], + digest: string, +) => { + expect(result.archive_sha256).toBe(digest); + expect(result.roots).toEqual({ root: { CF$UID: 1 } }); + expect( + result.objects.map(({ id, kind: objectKind, status }) => ({ + id, + kind: objectKind, + status, + })), + ).toEqual([ + { id: 0, kind: "nil", status: "nil" }, + { id: 1, kind: "scalar", status: "observed" }, + ]); + expect(result.references).toEqual([ + { + source: null, + path: ["root"], + target: 1, + status: "resolved", + raw: { CF$UID: 1 }, + }, + ]); + expect(result.total_objects).toBe(2); + expect(result.total_references).toBe(1); + expect(result.offset).toBe(0); + expect(result.next_offset).toBeNull(); + expect(result.truncated).toBe(false); + const value = result.objects.find(({ id }) => id === 1)?.value; + expect(value).toBeDefined(); + if (kind === "integral-real") expect(value).toBe(9007199254740992); + else { + const exact = + typeof value === "object" && + value !== null && + JSON.stringify(value) === + JSON.stringify({ $plist_type: "integer", decimal: "9007199254740993" }); + const disclosed = result.limitations.some( + (note) => + /integer|number/iu.test(note) && + /precision|exact range|rounded/iu.test(note), + ); + expect(exact || disclosed).toBe(true); + } +}; +it.each(cases)( + "filesystem preserves primitive scalar graph for %s", + async (kind) => { + const item = await fixture(kind); + verify( + await inspectBundleKeyedArchive({ + bundlePath: item.root, + targetSha256: item.digest, + parameters: { path: "archive.plist", limit: 100 }, + }), + kind, + item.digest, + ); + }, +); +cliTest.for(cases)( + "built CLI preserves primitive scalar graph for %s", + async (kind, { cli }) => { + const item = await fixture(kind); + const output = await cli.run({ + arguments: ["inspect-keyed-archive", item.path, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode).toBe(0); + verify( + keyedArchiveResultSchema.parse( + parseEvidence(output.json).normalized_result, + ), + kind, + item.digest, + ); + }, +); diff --git a/tests/boundary/mcp/keyedArchiveNumberMetadataMcp.test.ts b/tests/boundary/mcp/keyedArchiveNumberMetadataMcp.test.ts new file mode 100644 index 000000000..88b415de5 --- /dev/null +++ b/tests/boundary/mcp/keyedArchiveNumberMetadataMcp.test.ts @@ -0,0 +1,44 @@ +import { expect } from "vitest"; +import { z } from "zod"; + +import { createBinarySession } from "../../../src/composition/binary.js"; +import { parseConfig } from "../../../src/config.js"; +import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { + archiveNumberMetadataCases, + expectKeyedArchiveNumberMetadata, + keyedArchiveNumberMetadataFixture, +} from "../../fixtures/keyedArchiveNumberMetadata.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +mcpTest.for(archiveNumberMetadataCases)( + "MCP preserves numeric meaning for $name", + async (item, { mcp, onTestFinished }) => { + const fixture = await keyedArchiveNumberMetadataFixture(item); + const configured = parseConfig({ REA_ANALYSIS_PROVIDER: "auto" }); + if (!configured.ok) throw configured.error; + const session = createBinarySession(configured.value); + onTestFinished(async () => { + await session.close(); + }); + const client = await mcp.connect(createServer(session, session)); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: fixture.path }, + }); + expect(opened.isError, JSON.stringify(opened.content)).not.toBe(true); + const result = await client.callTool({ + name: "inspect_keyed_archive", + arguments: {}, + }); + expect(result.isError, JSON.stringify(result.content)).not.toBe(true); + expectKeyedArchiveNumberMetadata( + z + .object({ result: keyedArchiveResultSchema }) + .parse(result.structuredContent).result, + item, + fixture.digest, + ); + }, +); diff --git a/tests/conformance/native/keyed-archive-integers.swift b/tests/conformance/native/keyed-archive-integers.swift new file mode 100644 index 000000000..6f98cbb0c --- /dev/null +++ b/tests/conformance/native/keyed-archive-integers.swift @@ -0,0 +1,134 @@ +import Foundation +import CryptoKit + +// Foundation writes the archive. XMLParser only observes inert plist element types. +final class PlistElements: NSObject, XMLParserDelegate { + struct Frame { + let kind: String + var values: [Any] = [] + var fields: [String: Any] = [:] + var key: String? + var text = "" + } + var frames: [Frame] = [] + var value: Any? + func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) { + frames.append(Frame(kind: name)) + } + func parser(_ parser: XMLParser, foundCharacters text: String) { + if !frames.isEmpty { frames[frames.count - 1].text += text } + } + func parser(_ parser: XMLParser, didEndElement name: String, namespaceURI: String?, qualifiedName: String?) { + let frame = frames.removeLast() + if name == "key" { + frames[frames.count - 1].key = frame.text + return + } + let item: Any + switch name { + case "dict": item = frame.fields + case "array": item = frame.values + case "integer", "real": + item = ["numberKind": name, "literal": frame.text.trimmingCharacters(in: .whitespacesAndNewlines)] + case "string": item = frame.text + case "true": item = true + case "false": item = false + case "plist": value = frame.values.first; return + default: parser.abortParsing(); return + } + if frames.last?.kind == "dict" { + guard let key = frames.last?.key else { parser.abortParsing(); return } + frames[frames.count - 1].fields[key] = item + frames[frames.count - 1].key = nil + } else if !frames.isEmpty { + frames[frames.count - 1].values.append(item) + } + } +} +func elements(_ bytes: Data) throws -> [String: Any] { + let delegate = PlistElements() + let parser = XMLParser(data: bytes) + parser.shouldResolveExternalEntities = false + parser.delegate = delegate + guard parser.parse(), let value = delegate.value as? [String: Any] else { + throw NSError(domain: "REA.IntegerOracle", code: 1) + } + return value +} +func rows(_ value: Any, path: [Any] = []) -> [[String: Any]] { + if let fields = value as? [String: Any] { + // Reference markers identify objects; their UID is not an archived numeric value. + if fields.count == 1 && fields["CF$UID"] != nil { return [] } + if let kind = fields["numberKind"] as? String, let literal = fields["literal"] as? String { + return [["path": path, "kind": kind, "literal": literal]] + } + return fields.keys.sorted().flatMap { rows(fields[$0]!, path: path + [$0]) } + } + if let values = value as? [Any] { + return values.enumerated().flatMap { rows($0.element, path: path + [$0.offset]) } + } + return [] +} +func literal(_ value: Any?, kind: String) throws -> String { + guard let fields = value as? [String: String], fields["numberKind"] == kind, let text = fields["literal"] else { + throw NSError(domain: "REA.IntegerOracle", code: 2) + } + return text +} +let output = URL(fileURLWithPath: CommandLine.arguments[1]) +let positive: Int64 = 9_007_199_254_740_993 +let negative: Int64 = Int64.min +let unsigned: UInt64 = 9_007_199_254_740_993 +let real: Double = 9_007_199_254_740_992 +var archives: [String: Any] = [:] +for format in ["xml", "binary"] { + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + archiver.outputFormat = format == "xml" ? .xml : .binary + archiver.encode(positive, forKey: "unsafeSigned") + archiver.encode(negative, forKey: "unsafeNegative") + archiver.encode(Int64(9_007_199_254_740_991), forKey: "safeInteger") + archiver.encode(Int64(-42), forKey: "safeNegative") + archiver.encode(real, forKey: "integralReal") + archiver.encode(NSNumber(value: unsigned), forKey: "boxedUnsigned") + archiver.encode(NSNumber(value: real), forKey: "boxedReal") + archiver.finishEncoding() + let bytes = archiver.encodedData + let native = try PropertyListSerialization.propertyList(from: bytes, options: [], format: nil) + let xml = try PropertyListSerialization.data(fromPropertyList: native, format: .xml, options: 0) + let parsed = try elements(xml) + guard let top = parsed["$top"] as? [String: Any], let table = parsed["$objects"] as? [Any] else { + throw NSError(domain: "REA.IntegerOracle", code: 3) + } + guard try literal(top["unsafeSigned"], kind: "integer") == String(positive), + try literal(top["unsafeNegative"], kind: "integer") == String(negative), + try literal(top["safeInteger"], kind: "integer") == "9007199254740991", + try literal(top["safeNegative"], kind: "integer") == "-42", + Double(try literal(top["integralReal"], kind: "real")) == real else { + throw NSError(domain: "REA.IntegerOracle", code: 4) + } + var selectedRows: [[String: Any]] = [] + for key in ["unsafeSigned", "unsafeNegative", "safeInteger", "safeNegative", "integralReal"] { + for row in rows(top[key]!) { + selectedRows.append(row.merging(["container": "roots", "path": [key], "root": key]) { _, rhs in rhs }) + } + } + for key in ["boxedUnsigned", "boxedReal"] { + guard let reference = top[key] as? [String: Any], + let uid = Int(try literal(reference["CF$UID"], kind: "integer")), uid > 0, uid < table.count else { + throw NSError(domain: "REA.IntegerOracle", code: 5) + } + let numericRows = rows(table[uid]) + let expectedKind = key == "boxedUnsigned" ? "integer" : "real" + guard numericRows.contains(where: { row in + guard row["kind"] as? String == expectedKind, let text = row["literal"] as? String else { return false } + return key == "boxedUnsigned" ? text == String(unsigned) : Double(text) == real + }) else { throw NSError(domain: "REA.IntegerOracle", code: 6) } + for row in numericRows { + selectedRows.append(row.merging(["container": "objects", "objectId": uid, "root": key]) { _, rhs in rhs }) + } + } + let filename = "numbers.\(format).plist" + try bytes.write(to: output.appendingPathComponent(filename)) + archives[format] = ["filename": filename, "sha256": SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined(), "rows": selectedRows] +} +try JSONSerialization.data(withJSONObject: archives, options: [.sortedKeys]).write(to: output.appendingPathComponent("oracle.json")) diff --git a/tests/fixtures/keyedArchiveIntegers.ts b/tests/fixtures/keyedArchiveIntegers.ts new file mode 100644 index 000000000..239319ad0 --- /dev/null +++ b/tests/fixtures/keyedArchiveIntegers.ts @@ -0,0 +1,143 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { expect } from "vitest"; +import { z } from "zod"; +import { keyedArchiveResultSchema } from "../../src/domain/apple/keyedArchive.js"; +import { createTestTempDirectory } from "./temporaryDirectory.js"; + +const rowSchema = z.object({ + container: z.enum(["roots", "objects"]), + root: z.string(), + objectId: z.number().int().nonnegative().optional(), + path: z.array(z.union([z.string(), z.number().int().nonnegative()])), + kind: z.enum(["integer", "real"]), + literal: z.string(), +}); +const archiveSchema = z.object({ + filename: z.string(), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + rows: z.array(rowSchema), +}); +const oracleSchema = z.object({ xml: archiveSchema, binary: archiveSchema }); +export const keyedArchiveIntegerFormats = ["xml", "binary"] as const; +export const keyedArchiveIntegerCases = ["controls", "unsafe"] as const; +type Format = (typeof keyedArchiveIntegerFormats)[number]; +type Case = (typeof keyedArchiveIntegerCases)[number]; +type Row = z.infer; +export const keyedArchiveIntegerFixture = async (format: Format) => { + const root = await createTestTempDirectory("rea-keyed-integers-"); + await promisify(execFile)( + "/usr/bin/xcrun", + [ + "swift", + "-module-cache-path", + join(root, "modules"), + "tests/conformance/native/keyed-archive-integers.swift", + root, + ], + { maxBuffer: 1024 * 1024 }, + ); + const oracle = oracleSchema.parse( + JSON.parse(await readFile(join(root, "oracle.json"), "utf8")), + ); + for (const entry of [oracle.xml, oracle.binary]) { + expect(entry.rows).toContainEqual( + expect.objectContaining({ + root: "unsafeSigned", + kind: "integer", + literal: "9007199254740993", + }), + ); + expect(entry.rows).toContainEqual( + expect.objectContaining({ + root: "unsafeNegative", + kind: "integer", + literal: "-9223372036854775808", + }), + ); + expect( + entry.rows.some( + (row) => + row.root === "boxedUnsigned" && + row.kind === "integer" && + row.literal === "9007199254740993", + ), + ).toBe(true); + expect( + entry.rows.some( + (row) => + row.root === "boxedReal" && + row.kind === "real" && + Number(row.literal) === 9007199254740992, + ), + ).toBe(true); + } + const selected = oracle[format]; + const archivePath = join(root, selected.filename); + const bytes = await readFile(archivePath); + const digest = createHash("sha256").update(bytes).digest("hex"); + expect(digest).toBe(selected.sha256); + return { root, selected, archivePath, digest }; +}; +const at = (value: unknown, path: Row["path"]): unknown => { + for (const key of path) { + if (value === null || typeof value !== "object") return undefined; + value = (value as Record)[key]; + } + return value; +}; +export const expectKeyedArchiveIntegerCase = ( + result: z.infer, + selectedCase: Case, + selected: z.infer, + digest: string, +) => { + expect(result.archive_sha256).toBe(digest); + const unsafe = (row: Row) => + row.kind === "integer" && + (BigInt(row.literal) > BigInt(Number.MAX_SAFE_INTEGER) || + BigInt(row.literal) < BigInt(Number.MIN_SAFE_INTEGER)); + const rows = selected.rows.filter((row) => + selectedCase === "unsafe" ? unsafe(row) : !unsafe(row), + ); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + const value = at( + row.container === "roots" + ? result.roots + : result.objects.find((object) => object.id === row.objectId)?.value, + row.path, + ); + expect( + value, + `${row.root} numeric slot remains present`, + ).not.toBeUndefined(); + if (unsafe(row)) { + const exact = + typeof value === "object" && + value !== null && + (value as Record).$plist_type === "integer" && + (value as Record).decimal === + BigInt(row.literal).toString(); + // The accepted alternate contract must explicitly disclose numeric precision loss. + const disclosed = result.limitations.some( + (reason) => + /integer|number/iu.test(reason) && + /precision|exact range|rounded/iu.test(reason), + ); + expect( + exact || disclosed, + `${row.root} ${row.literal} must be exact or disclose numeric loss`, + ).toBe(true); + } else { + expect(typeof value, `${row.root} retains its numeric type`).toBe( + "number", + ); + expect(value).toBe(Number(row.literal)); + } + } +}; diff --git a/tests/fixtures/keyedArchiveNumberMetadata.ts b/tests/fixtures/keyedArchiveNumberMetadata.ts new file mode 100644 index 000000000..f63b86fb0 --- /dev/null +++ b/tests/fixtures/keyedArchiveNumberMetadata.ts @@ -0,0 +1,154 @@ +import { createHash } from "node:crypto"; +import { 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 type { JsonValue } from "../../src/domain/jsonValue.js"; +import { createTestTempDirectory } from "./temporaryDirectory.js"; + +const EXACT_INTEGER = { $plist_type: "integer", decimal: "9007199254740993" }; +const ROUNDED = 9007199254740992; + +/** Accepted decoder representations whose supplemental metadata needs care. */ +export const mixedArchiveNumberElements = [ + "9007199254740992", + "9007199254740992", + "90071992547409923", + "9007199254740992", + "3", + "", + "90071992547409923", + "90071992547409923", + "9007199254740992x", +] as const; + +/** Ordinary dictionaries and malformed numeric references at the same boundary. */ +export const archiveNumberMetadataCases = [ + ...mixedArchiveNumberElements.map((element) => ({ + name: element, + source: { first: "__INTEGER__", other: "__OTHER__" }, + element, + expected: { first: ROUNDED, other: ROUNDED }, + incomplete: true, + malformedReference: false, + })), + ...(["UID", "CF$UID"] as const).flatMap((key) => [ + { + name: `${key} dictionary data`, + source: { [key]: { amount: "__INTEGER__" } }, + element: "", + expected: { [key]: { amount: EXACT_INTEGER } }, + incomplete: false, + malformedReference: false, + }, + { + name: `${key} array data`, + source: { [key]: ["__INTEGER__"] }, + element: "", + expected: { [key]: [EXACT_INTEGER] }, + incomplete: false, + malformedReference: false, + }, + { + name: `${key} malformed numeric reference`, + source: { [key]: "__INTEGER__" }, + element: "", + expected: { [key]: ROUNDED }, + incomplete: false, + malformedReference: true, + }, + ]), + { + name: "entity-encoded integer text", + source: { amount: "__INTEGER__" }, + element: "", + expected: { amount: EXACT_INTEGER }, + incomplete: false, + malformedReference: false, + integerElement: "9007199254740993", + }, +] satisfies readonly { + name: string; + source: JsonValue; + element: string; + expected: JsonValue; + incomplete: boolean; + malformedReference: boolean; + integerElement?: string; +}[]; + +type MetadataCase = (typeof archiveNumberMetadataCases)[number]; + +/** Write one inert archive and retain the original byte digest for assertions. */ +export const keyedArchiveNumberMetadataFixture = async (item: MetadataCase) => { + const root = await createTestTempDirectory("rea-keyed-number-metadata-"); + const xml = build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", item.source], + $top: { root: { CF$UID: 1 } }, + }) + .replace( + "__INTEGER__", + "integerElement" in item + ? item.integerElement + : "9007199254740993", + ) + .replace("__OTHER__", item.element); + const bytes = Buffer.from(xml); + const path = join(root, "archive.plist"); + await writeFile(path, bytes); + return { + root, + path, + digest: createHash("sha256").update(bytes).digest("hex"), + }; +}; + +/** Require exact ordinary values and honest limitations for incomplete metadata. */ +export const expectKeyedArchiveNumberMetadata = ( + value: unknown, + item: MetadataCase, + digest: string, +) => { + const result = keyedArchiveResultSchema.parse(value); + expect(result.archive_sha256).toBe(digest); + expect(result.roots).toEqual({ root: { CF$UID: 1 } }); + expect(result.objects).toHaveLength(2); + expect(result.objects[1]).toMatchObject({ + id: 1, + kind: "dictionary", + value: item.expected, + }); + expect(result.references).toContainEqual({ + source: null, + path: ["root"], + target: 1, + status: "resolved", + raw: { CF$UID: 1 }, + }); + if (item.incomplete) { + expect(result.limitations).toContainEqual( + expect.stringMatching(/metadata.*completely observed.*precision/u), + ); + expect( + result.limitations.some((note) => note.includes("unambiguous integer")), + ).toBe(false); + } else if (item.malformedReference) { + expect(result.references).toContainEqual({ + source: 1, + path: [], + target: null, + status: "malformed", + raw: item.expected, + }); + } else { + expect(result.references).toHaveLength(1); + expect(result.limitations).toContainEqual( + expect.stringMatching(/^1 unambiguous integer value/u), + ); + } +};