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), + ); + } +};