diff --git a/docs/native-investigation.md b/docs/native-investigation.md index ced5a6283a..ebcf02774b 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -587,6 +587,18 @@ 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. +Interface Builder's XML and binary plist archives use the same number metadata +and exact-decimal representation for emitted node attributes. Projection follows +object and connection classification, so archive IDs, UID links and graph counts +keep their meaning. Precision limitations name the source document. Unsafe +integer-literal counts cover the archive, including fields not emitted as node +attributes; exact and ambiguous projection counts cover emitted attribute values. +Integral reals stay numbers, and ambiguous or incompletely observed values retain +explicit precision limitations. Before plist decoding, the existing aggregate +decode budget reserves estimated room for number metadata and attribute +projection; these estimates are not a hard process-memory bound. Native +NIBArchive decoding is unchanged by this plist-specific representation. + Dates are reported as `{ "$plist_type": "date", "iso8601": "" }`. A date the decoder cannot represent, such as an XML date with a year beyond 9999 or a negative year, is reported as `{ "$plist_type": "date", "iso8601": null }` diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index c8837f969e..b527c14e0d 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -36,6 +36,11 @@ import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js"; +import { + binaryPlistNumberLiterals, + xmlPlistNumberLiterals, +} from "./PlistNumberLiterals.js"; +import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js"; import { hasBinaryPlistSignature, hasNibArchiveSignature, @@ -129,6 +134,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { bytes, decodeBudget.remainingBytes, xmlText, + { numberProjection: true }, ); decodeBudget.reserve( decodedReservation.estimatedBytes, @@ -142,9 +148,15 @@ export const analyzeInterfaceBuilderBundle = async (input: { value: raw, omittedPrototypeKeys, unknownDateCount, + numberLiterals, } = nib === null ? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved) - : { value: nib.raw, omittedPrototypeKeys: 0, unknownDateCount: 0 }; + : { + value: nib.raw, + omittedPrototypeKeys: 0, + unknownDateCount: 0, + numberLiterals: undefined, + }; if (nib !== null && nib.omitted > 0) incompleteHierarchies.set(entry.path, nib.omitted); if (nib !== null && nib.unresolvedConnections > 0) @@ -161,6 +173,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { ? "storyboard_scene" : "nib", raw, + ...(numberLiterals === undefined ? {} : { numberLiterals }), ...(nib === null || nib.omitted === 0 ? {} : { omittedSerializedHierarchyReferences: nib.omitted }), @@ -733,6 +746,7 @@ const decodePlist = ( readonly value: JsonValue; readonly omittedPrototypeKeys: number | null; readonly unknownDateCount: number; + readonly numberLiterals: PlistNumberLiterals; } => { const binary = hasBinaryPlistSignature(bytes); const { value, omittedPrototypeKeys } = binary @@ -746,5 +760,8 @@ const decodePlist = ( value: projected.value, omittedPrototypeKeys, unknownDateCount: projected.unknownDateCount, + numberLiterals: binary + ? binaryPlistNumberLiterals(bytes) + : xmlPlistNumberLiterals(xmlText ?? decodeXmlPlistText(bytes)), }; }; diff --git a/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts b/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts index b02aee7459..0ec14669b2 100644 --- a/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts +++ b/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { buildBinary } from "plist"; import { describe, expect, it } from "vitest"; import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js"; @@ -10,6 +11,34 @@ import { parseXmlPropertyList } from "../../domain/propertyListKeys.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; const example = ""; + +it.each([ + [ + "XML", + Buffer.from( + "9007199254740993", + ), + ], + ["binary", Buffer.from(buildBinary([9007199254740992]))], +] as const)( + "reserves %s number metadata and value projection before decoding", + (_format, bytes) => { + const ordinary = preflightPropertyListDecode( + bytes, + 1024 * 1024, + ).estimatedBytes; + expect(() => + preflightPropertyListDecode(bytes, ordinary, undefined, { + numberProjection: true, + }), + ).toThrow(/budget/u); + expect(() => + preflightPropertyListDecode(bytes, 1024 * 1024, undefined, { + numberProjection: true, + }), + ).not.toThrow(); + }, +); const archive = (content: string, comment = "") => `${comment}$archiverNSKeyedArchiver$objects$null$classCF$UID2title${content}$classnameNSButton$classesNSButtonNSObject$toprootCF$UID1`; diff --git a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts index 2eb11bf117..d6292c3098 100644 --- a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts +++ b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts @@ -102,11 +102,18 @@ export const preflightPropertyListDecode = ( bytes: Buffer, maxBytes: number, xmlText?: string, + options: Pick = {}, ): PlistDecodePreflight => { + const { numberProjection = false } = options; const expansion = hasBinaryPlistSignature(bytes) - ? estimateBinaryPlistExpansion(bytes, maxBytes) + ? estimateBinaryPlistExpansion(bytes, maxBytes, { numberProjection }) : { - estimatedBytes: estimateXmlPlistExpansion(bytes, maxBytes, xmlText), + estimatedBytes: estimateXmlPlistExpansion( + bytes, + maxBytes, + xmlText, + numberProjection, + ), binaryPrototypeKeyObserved: false, }; if (expansion.estimatedBytes > maxBytes) @@ -121,10 +128,13 @@ const estimateXmlPlistExpansion = ( bytes: Buffer, maxBytes: number, xmlText?: string, + numberProjection = false, ): number => { const xml = xmlText ?? decodeXmlPlistText(bytes); const decodedSourceBytes = xml.length * 2; let elementCount = 0; + // Reserve room for numeric source metadata and the projected attribute copy. + const elementBytes = numberProjection ? 256 : 128; // Value nesting is bounded by the shared plist parser, which counts plist // value depth; raw element depth would also count the envelope and keys. for (const token of xmlPreflightTokens(xml)) { @@ -139,10 +149,10 @@ const estimateXmlPlistExpansion = ( continue; } elementCount += 1; - if (decodedSourceBytes * 2 + elementCount * 128 > maxBytes) + if (decodedSourceBytes * 2 + elementCount * elementBytes > maxBytes) return maxBytes + 1; } - return decodedSourceBytes * 2 + elementCount * 128; + return decodedSourceBytes * 2 + elementCount * elementBytes; }; /** Consume each XML span once; malformed tokens cannot restart suffix scans. */ @@ -197,6 +207,8 @@ const interfaceBuilderBudgetFailure = ( /** How a caller names and reports binary plist expansion failures. */ export interface BinaryPlistExpansionOptions { + /** Include original number metadata and exact-integer attribute projection. */ + readonly numberProjection?: boolean; /** Budget named in failure messages. */ readonly budget?: string; /** Error for budget, depth, and reference-cycle failures. */ @@ -229,6 +241,7 @@ export const estimateBinaryPlistExpansion = ( budget = "the aggregate Interface Builder decode budget", fail = interfaceBuilderBudgetFailure, bounds = "strict", + numberProjection = false, }: BinaryPlistExpansionOptions = {}, ): PlistDecodePreflight => { if (bytes.length < 40) @@ -261,7 +274,10 @@ export const estimateBinaryPlistExpansion = ( offsetTable + objectCount * offsetSize > tableEnd ) throw new TypeError("binary plist trailer or object table is invalid"); - let estimate = bytes.length * 2 + objectCount * 16; + // Metadata covers the full object table; projected values may repeat through references. + const objectBytes = numberProjection ? 144 : 16; + const expandedNodeBytes = numberProjection ? 224 : 96; + let estimate = bytes.length * 2 + objectCount * objectBytes; let binaryPrototypeKeyObserved = false; if (estimate > maxBytes) throw fail("expansion", `binary plist object table exceeds ${budget}`); @@ -286,7 +302,7 @@ export const estimateBinaryPlistExpansion = ( throw fail("depth", `binary plist nesting exceeds ${budget}`); if (active.has(entry.object)) throw fail("cycle", `binary plist reference cycle exceeds ${budget}`); - estimate += 96; + estimate += expandedNodeBytes; if (estimate > maxBytes) throw fail( "expansion", @@ -352,7 +368,9 @@ export const estimateBinaryPlistExpansion = ( const refsEnd = cursor + childCount * referenceSize; if (!Number.isSafeInteger(refsEnd) || refsEnd > objectsEnd) throw new TypeError("binary plist reference table is truncated"); - const availableNodes = Math.floor((maxBytes - estimate) / 96); + const availableNodes = Math.floor( + (maxBytes - estimate) / expandedNodeBytes, + ); if (childCount > availableNodes - pendingObjectCount) throw fail( "expansion", diff --git a/src/domain/apple/interfaceBuilderGraph.ts b/src/domain/apple/interfaceBuilderGraph.ts index 1539412da7..005bd23b39 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import { + createPlistNumberProjection, + type PlistNumberLiterals, +} from "./plistNumbers.js"; import { firstString, @@ -35,6 +39,8 @@ export interface InterfaceBuilderDocumentInput { readonly archiveSha256: string; readonly documentKind: "nib" | "storyboard_scene"; readonly raw: unknown; + /** Original plist number types, observed before lossy JavaScript decoding. */ + readonly numberLiterals?: PlistNumberLiterals; /** Serialized hierarchy links omitted before normalized graph projection. */ readonly omittedSerializedHierarchyReferences?: number; } @@ -59,6 +65,7 @@ export const buildInterfaceBuilderAnalysis = (input: { omitted: number; }>; const summaries: Array> = []; + const numberLimitations: string[] = []; let truncated = false; let omittedObjects = 0; let omittedConnections = 0; @@ -66,6 +73,10 @@ export const buildInterfaceBuilderAnalysis = (input: { let omittedHierarchyReferences = 0; for (const document of input.documents.slice(0, input.limits.max_documents)) { const parsed = parseInterfaceBuilderRecords(document.raw); + const numbers = + document.numberLiterals === undefined + ? undefined + : createPlistNumberProjection(document.numberLiterals); const prefix = `ib:${JSON.stringify(document.relativePath)}:`; const graphId = (kind: string, parts: readonly unknown[] = []): string => `${prefix}${kind}:${JSON.stringify(parts)}`; @@ -89,7 +100,22 @@ export const buildInterfaceBuilderAnalysis = (input: { omittedGraphNodes += 1; return false; } - nodes.set(node.id, node); + // Resolve graph structure first; exact integer representation affects only + // serialized attributes, never object identity or UID-derived links. + nodes.set( + node.id, + numbers === undefined + ? node + : { + ...node, + attributes: Object.fromEntries( + Object.entries(node.attributes).map(([key, value]) => [ + key, + numbers.project(value), + ]), + ), + }, + ); return true; }; const rootNode = { @@ -443,6 +469,11 @@ export const buildInterfaceBuilderAnalysis = (input: { : hierarchyTruncated ? "partial" : "complete"; + numberLimitations.push( + ...(numbers?.limitations() ?? []).map( + (note) => `${document.relativePath}: ${note}`, + ), + ); summaries.push({ relative_path: document.relativePath, archive_sha256: document.archiveSha256, @@ -562,6 +593,7 @@ export const buildInterfaceBuilderAnalysis = (input: { omittedHierarchyReferences > 0, }, limitations: [ + ...numberLimitations, "The graph reflects recognized Interface Builder archive fields and connections; omitted private or unrecognized archive fields remain unknown.", "A target-action selector is an Interface Builder observation and does not prove that the binary contains a matching implementation.", "Decoded constraints, runtime visibility, dynamic UI creation, and pixel geometry are not inferred from these archives.", diff --git a/tests/acceptance/applications/interfaceBuilderPlistNumbers.test.ts b/tests/acceptance/applications/interfaceBuilderPlistNumbers.test.ts new file mode 100644 index 0000000000..a70f3e1aa2 --- /dev/null +++ b/tests/acceptance/applications/interfaceBuilderPlistNumbers.test.ts @@ -0,0 +1,147 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { build, buildBinary } from "plist"; +import { expect } from "vitest"; + +import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const scenarios = [ + "XML", + "binary", + "integer collision", + "integer-real collision", + "real", + "mixed XML", +] as const; + +cliTest.for(scenarios)( + "preserves numeric meaning and graph identity for %s Interface Builder attributes", + async (scenario, { cli }) => { + const root = await createTestTempDirectory("rea-ib-plist-numbers-"); + const app = join(root, "Numbers.app"); + const contents = join(app, "Contents"); + const resources = join(contents, "Resources"); + await mkdir(resources, { recursive: true }); + await mkdir(join(contents, "MacOS")); + await writeFile( + join(contents, "MacOS", "Fixture"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await writeFile( + join(contents, "Info.plist"), + build({ CFBundleExecutable: "Fixture" }), + ); + const source = { + $archiver: "NSKeyedArchiver", + $version: 100000, + $top: { root: { CF$UID: 1 } }, + $objects: [ + "$null", + { + $class: { CF$UID: 3 }, + large: scenario === "binary" ? 9007199254740992 : "__INTEGER__", + safe: 42, + children: [{ CF$UID: 2 }], + ...(scenario.includes("collision") ? { other: "__OTHER__" } : {}), + }, + { $class: { CF$UID: 3 }, label: "Child" }, + { $classname: "UIView", $classes: ["UIView", "NSObject"] }, + ], + }; + const bytes = + scenario === "binary" + ? Buffer.from(buildBinary(source)) + : Buffer.from( + build(source) + .replace( + "__INTEGER__", + scenario === "real" + ? "9007199254740992" + : scenario === "mixed XML" + ? "90071992547409923" + : "9007199254740993", + ) + .replace( + "__OTHER__", + scenario === "integer collision" + ? "9007199254740992" + : "9007199254740992", + ), + ); + if (scenario === "binary") { + // The builder emits an unsafe JS number as a real. Replace that one + // 8-byte scalar with an exact signed integer of the same byte width. + const marker = Buffer.alloc(9); + marker[0] = 0x23; + marker.writeDoubleBE(9007199254740992, 1); + const offset = bytes.indexOf(marker); + expect(offset).toBeGreaterThan(0); + expect(bytes.indexOf(marker, offset + marker.length)).toBe(-1); + bytes[offset] = 0x13; + bytes.writeBigInt64BE(9007199254740993n, offset + 1); + } + await writeFile(join(resources, "Main.nib"), bytes); + const output = await cli.run({ + arguments: ["decode-interface-builder", app, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode, output.stderr).toBe(0); + const result = interfaceBuilderAnalysisSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(result.documents).toEqual([ + expect.objectContaining({ + archive_sha256: createHash("sha256").update(bytes).digest("hex"), + object_count: 2, + }), + ]); + expect( + result.graph.nodes.find( + ({ attributes }) => attributes.interface_builder_object_id === "1", + ), + ).toMatchObject({ + id: 'ib:"Contents/Resources/Main.nib":object:["1"]', + attributes: { + large: + scenario === "XML" || scenario === "binary" + ? { $plist_type: "integer", decimal: "9007199254740993" } + : 9007199254740992, + safe: 42, + children: [{ archiveUID: 2, className: "UIView" }], + interface_builder_object_id: "1", + class_name: "UIView", + }, + }); + expect(result.graph.nodes).toHaveLength(3); + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + from: 'ib:"Contents/Resources/Main.nib":object:["1"]', + to: 'ib:"Contents/Resources/Main.nib":object:["2"]', + relation: "contains", + }), + ); + if (scenario === "XML" || scenario === "binary") + expect(result.limitations).toContainEqual( + expect.stringContaining("1 unambiguous integer value"), + ); + else if (scenario !== "real") + expect(result.limitations).toContainEqual( + expect.stringContaining("integer precision loss remains possible"), + ); + else + expect( + result.limitations.some((note) => note.includes("integer precision")), + ).toBe(false); + if (scenario === "mixed XML") + expect(result.limitations).toContainEqual( + expect.stringContaining("could not be completely observed"), + ); + expect(result.graph.truncated).toBe(false); + }, +);