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 unknownMixedInteger = { $plist_type: "unknown", element: "integer", literal: "90071992547409923", reason: "unsupported-markup", }; const scenarios = [ "XML", "binary", "integer collision", "integer-real collision", "real", "CDATA real", "hexadecimal", "mixed XML", "segmented keys", ] 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"), scenario === "segmented keys" ? "CFBundleFixture" : 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"] }, ], }; let bytes = scenario === "binary" ? Buffer.from(buildBinary(source)) : Buffer.from( build(source) .replace( "__INTEGER__", scenario === "real" ? "9007199254740992" : scenario === "CDATA real" ? "9007199254" : scenario === "hexadecimal" ? "0x20000000000001" : 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); } if (scenario === "segmented keys") bytes = Buffer.from( bytes .toString("utf8") .replaceAll("$class", "$cl") .replaceAll("children", "chil") .replaceAll("safe", "sa"), ); 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" || scenario === "hexadecimal" || scenario === "segmented keys" ? { $plist_type: "integer", decimal: "9007199254740993" } : scenario === "mixed XML" ? unknownMixedInteger : 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" || scenario === "hexadecimal" || scenario === "segmented keys" ) expect(result.limitations).toContainEqual( expect.stringContaining("1 unambiguous integer value"), ); else if (scenario.includes("collision")) expect(result.limitations).toContainEqual( expect.stringContaining("integer precision loss remains possible"), ); else if (scenario === "mixed XML") expect(result.limitations).toContainEqual( expect.stringContaining( "1 XML scalar value(s) could not be established", ), ); else expect( result.limitations.some((note) => note.includes("integer precision")), ).toBe(false); expect(result.graph.truncated).toBe(false); }, );