diff --git a/src/application/InterfaceBuilderAnalysis.ts b/src/application/InterfaceBuilderAnalysis.ts index 697a1fd8f..f223dae3f 100644 --- a/src/application/InterfaceBuilderAnalysis.ts +++ b/src/application/InterfaceBuilderAnalysis.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { parseBinary } from "plist"; import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js"; +import { projectPlistValue } from "../domain/plistValue.js"; import { parseXmlPropertyList } from "../domain/propertyListKeys.js"; import { decodeNibArchive, @@ -370,10 +371,14 @@ const readEntry = async ( return Buffer.concat(chunks, length); }; -const decodePlist = (bytes: Buffer): JsonValue => { - const parsed = +/** + * Decode a keyed-archive plist. Data and dates, such as an archived color's + * `NSWhite` bytes, become explicit `$plist_type` values rather than failing + * JSON validation. + */ +const decodePlist = (bytes: Buffer): JsonValue => + projectPlistValue( bytes.subarray(0, 8).toString("ascii") === "bplist00" ? parseBinary(bytes) - : parseXmlPropertyList(bytes.toString("utf8")).value; - return jsonValueSchema.parse(parsed); -}; + : parseXmlPropertyList(bytes.toString("utf8")).value, + ).value; diff --git a/src/domain/interfaceBuilderGraph.test.ts b/src/domain/interfaceBuilderGraph.test.ts index f28743322..6a7061c87 100644 --- a/src/domain/interfaceBuilderGraph.test.ts +++ b/src/domain/interfaceBuilderGraph.test.ts @@ -317,3 +317,88 @@ describe("keyed archive decoding and graph limits", () => { ); }); }); + +describe("keyed archive object references", () => { + it("keeps referenced objects as stubs so shared and cyclic references stay finite", () => { + // Each view references its window, superview, and next key view, so + // inlining references would expand the table into an exponential tree. + const viewCount = 40; + const views = Array.from({ length: viewCount }, (_, index) => ({ + $class: { UID: 1 }, + NSWindow: { UID: 2 }, + NSSuperview: { UID: index === 0 ? 2 : 3 + index - 1 }, + NSNextKeyView: { UID: 3 + ((index + 1) % viewCount) }, + NSSubviews: index + 1 < viewCount ? [{ UID: 3 + index + 1 }] : [], + NSTitle: { UID: 3 + viewCount }, + })); + const parsed = parseInterfaceBuilderRecords({ + $archiver: "NSKeyedArchiver", + $objects: [ + "$null", + { $classname: "NSView", $classes: ["NSView", "NSObject"] }, + { $class: { UID: 3 + viewCount + 1 }, NSContentView: { UID: 3 } }, + ...views, + "Title", + { $classname: "NSWindow", $classes: ["NSWindow", "NSObject"] }, + ], + $top: { root: { UID: 2 } }, + }); + + expect(parsed.objects).toHaveLength(viewCount + 1); + const view = parsed.objects.find(({ id }) => id === "3"); + expect(view?.name).toBe("NSView"); + expect(view?.attributes).toEqual({ + NSWindow: { archiveUID: 2, className: "NSWindow" }, + NSSuperview: { archiveUID: 2, className: "NSWindow" }, + NSNextKeyView: { archiveUID: 4, className: "NSView" }, + NSSubviews: [{ archiveUID: 4, className: "NSView" }], + NSTitle: "Title", + }); + expect(parsed.hierarchy).toMatchObject([ + { + objectID: "2", + children: [{ objectID: "3", children: [{ objectID: "4" }] }], + }, + ]); + }); + + it("resolves XML CF$UID references and the archived nil", () => { + const parsed = parseInterfaceBuilderRecords({ + $archiver: "NSKeyedArchiver", + $objects: [ + "$null", + { + $class: { CF$UID: 3 }, + NSSource: { CF$UID: 2 }, + source: { CF$UID: 2 }, + destination: { CF$UID: 0 }, + label: { CF$UID: 4 }, + }, + { $class: { CF$UID: 5 } }, + { + $classname: "NSNibOutletConnector", + $classes: ["NSNibOutletConnector", "NSObject"], + }, + { $class: { CF$UID: 6 }, "NS.string": "delegate" }, + { $classname: "NSButton", $classes: ["NSButton", "NSObject"] }, + { + $classname: "NSMutableString", + $classes: ["NSMutableString", "NSString", "NSObject"], + }, + ], + $top: {}, + }); + + expect(parsed.connections).toEqual([ + expect.objectContaining({ + kind: "outlet", + source_id: "2", + destination_id: null, + label: "delegate", + }), + ]); + expect(parsed.objects.find(({ id }) => id === "2")).toMatchObject({ + class_name: "NSButton", + }); + }); +}); diff --git a/src/domain/interfaceBuilderKeyedArchive.ts b/src/domain/interfaceBuilderKeyedArchive.ts index 24378a75c..e08f0faf2 100644 --- a/src/domain/interfaceBuilderKeyedArchive.ts +++ b/src/domain/interfaceBuilderKeyedArchive.ts @@ -117,62 +117,130 @@ export const parseInterfaceBuilderRecords = ( }; }; +/** + * A UID decodes as a one-key `UID` (binary) or `CF$UID` (XML) dictionary with + * an integer value. Any other dictionary is ordinary archive data. + */ +const archiveUid = (value: unknown): number | undefined => { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return undefined; + if (Object.keys(value).length !== 1) return undefined; + const item = record(value); + const marker = Object.hasOwn(item, "UID") ? item.UID : item["CF$UID"]; + return typeof marker === "number" && Number.isInteger(marker) + ? marker + : undefined; +}; + +const HIERARCHY_KEY = + /(?:subviews|contentview|childviewcontrollers|children|views)$/iu; + +/** + * Read an NSKeyedArchiver object table without expanding it into a tree. + * Archived objects reference each other freely, including cycles such as a + * view and its superview, so inlining every reference grows exponentially. + * A reference instead resolves to the referenced string, or to a stub that + * names the referenced object; the hierarchy walk visits each object once. + */ +const keyedArchiveTable = (objectTable: readonly unknown[]) => { + const classNameOf = (value: unknown): string | null => { + const classReference = record(value).$class; + const uid = archiveUid(classReference); + return firstString( + record(uid === undefined ? classReference : objectTable[uid]).$classname, + ); + }; + /** The string an archived string object holds, if it is one. */ + const archivedString = (target: unknown): string | undefined => { + if (typeof target === "string") return target; + const text = record(target)["NS.string"]; + return typeof text === "string" && + /^NS(?:Mutable)?String$/u.test(classNameOf(target) ?? "") + ? text + : undefined; + }; + const text = (value: unknown): string | null => { + const uid = archiveUid(value); + return firstString( + uid === undefined ? value : archivedString(objectTable[uid]), + ); + }; + const authoredId = (item: Record): string | null => + firstString(text(item.objectID), text(item["object-id"])); + const reference = (uid: number): unknown => { + // UID 0 is the archived nil. + const target = uid === 0 ? null : objectTable[uid]; + if (target === undefined || target === null) return null; + if (typeof target !== "object") return target; + const string = archivedString(target); + if (string !== undefined) return string; + const objectId = authoredId(record(target)); + return { + archiveUID: uid, + ...(objectId === null ? {} : { objectID: objectId }), + className: classNameOf(target), + }; + }; + /** Resolve one object's own fields, leaving references to other objects as stubs. */ + const resolve = (value: unknown): unknown => { + const uid = archiveUid(value); + if (uid !== undefined) return reference(uid); + if (Array.isArray(value)) return value.map(resolve); + if (typeof value !== "object" || value === null) return value; + const output: Record = {}; + for (const [key, child] of Object.entries(value)) { + if (key === "$class") output.className = classNameOf(value); + else output[key] = resolve(child); + } + return output; + }; + const hierarchy = (start: unknown): unknown[] => { + const visited = new Set(); + const node = ( + item: Record, + uid: number | undefined, + depth: number, + ): unknown[] => { + const objectId = firstString( + authoredId(item), + text(item.id), + uid === undefined ? null : String(uid), + ); + const children = Object.entries(item) + .filter(([key]) => HIERARCHY_KEY.test(key)) + .flatMap(([, child]) => visit(child, depth + 1)); + return objectId === null ? children : [{ objectID: objectId, children }]; + }; + const visit = (value: unknown, depth: number): unknown[] => { + if (depth > 32) return []; + const uid = archiveUid(value); + if (uid !== undefined) { + if (visited.has(uid)) return []; + visited.add(uid); + const target = objectTable[uid]; + if (Array.isArray(target)) return visit(target, depth + 1); + return typeof target === "object" && target !== null + ? node(record(target), uid, depth) + : []; + } + if (Array.isArray(value)) + return value.flatMap((item) => visit(item, depth + 1)); + if (typeof value !== "object" || value === null) return []; + return node(record(value), undefined, depth); + }; + return visit(start, 0); + }; + return { resolve, hierarchy }; +}; + /** Project class and connection objects from an NSKeyedArchiver object table. */ const parseKeyedArchive = ( root: Record, ): ReturnType | null => { if (root.$archiver !== "NSKeyedArchiver" || !Array.isArray(root.$objects)) return null; - const objectTable = root.$objects; - const resolve = (value: unknown, depth = 0): unknown => { - if (depth > 16) return null; - const uid = record(value).UID; - if (typeof uid === "number") { - const target = objectTable[uid]; - const resolved = target === undefined ? null : resolve(target, depth + 1); - return typeof resolved === "object" && - resolved !== null && - !Array.isArray(resolved) - ? { ...resolved, archiveUID: uid } - : resolved; - } - if (Array.isArray(value)) - return value.map((item) => resolve(item, depth + 1)); - if (typeof value !== "object" || value === null) return value; - const output: Record = {}; - for (const [key, child] of Object.entries(value)) { - if (key === "$class") { - const classObject = record( - typeof record(child).UID === "number" - ? objectTable[record(child).UID as number] - : child, - ); - output.className = firstString(classObject.$classname); - } else output[key] = resolve(child, depth + 1); - } - return output; - }; - const toHierarchy = (value: unknown, depth = 0): unknown[] => { - if (depth > 32) return []; - if (Array.isArray(value)) - return value.flatMap((item) => toHierarchy(item, depth + 1)); - if (typeof value !== "object" || value === null) return []; - const item = record(value); - const objectId = firstString( - item.objectID, - item["object-id"], - item.id, - typeof item.archiveUID === "number" ? String(item.archiveUID) : null, - ); - const children = Object.entries(item) - .filter(([key]) => - /(?:subviews|contentview|childviewcontrollers|children|views)$/iu.test( - key, - ), - ) - .flatMap(([, child]) => toHierarchy(child, depth + 1)); - return objectId === null ? children : [{ objectID: objectId, children }]; - }; + const objectTable: readonly unknown[] = root.$objects; + const { resolve, hierarchy: toHierarchy } = keyedArchiveTable(objectTable); const objects: InterfaceBuilderObject[] = []; const connections: InterfaceBuilderConnection[] = []; let objectCount = 0; @@ -245,8 +313,7 @@ const parseKeyedArchive = ( } } const top = record(root.$top); - const topObject = resolve(top.root ?? top.UITopLevelObjectsKey); - const hierarchy = toHierarchy(topObject); + const hierarchy = toHierarchy(top.root ?? top.UITopLevelObjectsKey); const classes = Object.fromEntries( objectTable.flatMap((raw) => { const item = record(raw); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 77c8ef7aa..a6f9e4fc8 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -71,6 +71,48 @@ describe("compiled Interface Builder bundle reader", () => { ); }); + it("decodes flat nib archives that hold data and date values", async () => { + const root = await createTestTempDirectory("rea-ib-test-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources", "English.lproj"); + await mkdir(resources, { recursive: true }); + await writeFile( + join(resources, "Binary.nib"), + buildBinary({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: [ + "$null", + { $class: { UID: 2 }, title: "Build", NSColor: { UID: 3 } }, + { $classname: "NSButton", $classes: ["NSButton", "NSObject"] }, + { $class: { UID: 4 }, NSWhite: Buffer.from("0.5\0", "ascii") }, + { $classname: "NSColor", $classes: ["NSColor", "NSObject"] }, + ], + $top: { root: { UID: 1 } }, + }), + ); + await writeFile( + join(resources, "Xml.nib"), + '$archiverNSKeyedArchiver$objects$nullNSWhiteMC41AA==NSDate2026-10-07T00:00:00Z$top', + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "d".repeat(64), + }); + + expect( + analysis.documents.map(({ relative_path }) => relative_path).sort(), + ).toEqual([ + "Contents/Resources/English.lproj/Binary.nib", + "Contents/Resources/English.lproj/Xml.nib", + ]); + expect(analysis.graph.coverage).toContainEqual( + expect.objectContaining({ facet: "archive_decode", status: "complete" }), + ); + expect(analysis.graph.nodes.map(({ name }) => name)).toContain("Build"); + }); + it("honors cancellation during directory traversal", async () => { const root = await createTestTempDirectory("rea-ib-test-"); const controller = new AbortController();