diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts new file mode 100644 index 0000000000..f3e8b30c3f --- /dev/null +++ b/src/application/AppBundleExecutable.ts @@ -0,0 +1,186 @@ +import { execFile } from "node:child_process"; +import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { z } from "zod"; + +import { BinaryTargetError } from "../domain/configurationErrors.js"; +import { isPathWithinRoot } from "../domain/localPath.js"; +import { parseXmlPropertyList } from "../domain/propertyListKeys.js"; +import { err, ok, type Result } from "../domain/result.js"; + +const execFileAsync = promisify(execFile); + +/** Where one app bundle layout keeps its Info.plist and program file. */ +interface BundleLayout { + readonly plist: readonly string[]; + readonly programs: readonly string[]; + readonly programsLabel: string; +} + +const BUNDLE_LAYOUTS: readonly BundleLayout[] = [ + // macOS bundles keep both under Contents. + { + plist: ["Contents", "Info.plist"], + programs: ["Contents", "MacOS"], + programsLabel: "Contents/MacOS", + }, + // iOS-style bundles, as extracted from an IPA, keep both at the root. + { plist: ["Info.plist"], programs: [], programsLabel: "the bundle root" }, +]; + +/** A resolved bundle program file and the Info.plist that declared it. */ +export interface ResolvedAppBundle { + readonly executable: string; + readonly infoPlist?: string; +} + +/** + * Resolve an app bundle directory to its declared program file. macOS and + * flat iOS-style layouts are read directly. An iOS app installed on a Mac is + * a wrapper whose `Wrapper` directory holds the iOS bundle; that bundle is + * found by listing `Wrapper`, never by following the `WrappedBundle` link. + */ +export const resolveAppBundleExecutable = async ( + path: string, +): Promise> => { + const layout = await presentLayout(path); + if (layout !== undefined) return resolveLayoutExecutable(path, layout); + const wrapped = await wrappedBundle(path); + if (wrapped !== undefined) { + const wrappedLayout = await presentLayout(wrapped); + if (wrappedLayout !== undefined) + return resolveLayoutExecutable(wrapped, wrappedLayout); + } + return err( + new BinaryTargetError(path, "app has no readable CFBundleExecutable", { + cause: new Error( + "No Contents/Info.plist, root Info.plist, or single Wrapper/*.app bundle was found", + ), + }), + ); +}; + +const presentLayout = async ( + bundle: string, +): Promise => { + for (const layout of BUNDLE_LAYOUTS) + if (await layoutPlistPresent(join(bundle, ...layout.plist))) return layout; + return undefined; +}; + +/** + * Whether a layout's Info.plist exists. Readable symlinks count, as they did + * when the plist was read directly; any failure other than absence, such as + * a permission denial, is left for the caller to report. + */ +const layoutPlistPresent = async (path: string): Promise => { + try { + return (await stat(path)).isFile(); + } catch (cause: unknown) { + if (isAbsence(cause)) return false; + throw cause; + } +}; + +const isAbsence = (cause: unknown): boolean => { + const code: unknown = + cause instanceof Error ? Reflect.get(cause, "code") : undefined; + return code === "ENOENT" || code === "ENOTDIR"; +}; + +/** The single real `.app` directory inside an iOS-on-Mac `Wrapper`. */ +const wrappedBundle = async (bundle: string): Promise => { + const wrapper = join(bundle, "Wrapper"); + try { + if (!(await lstat(wrapper)).isDirectory()) return undefined; + } catch (cause: unknown) { + if (isAbsence(cause)) return undefined; + throw cause; + } + const apps = (await readdir(wrapper, { withFileTypes: true })).filter( + (entry) => entry.isDirectory() && entry.name.toLowerCase().endsWith(".app"), + ); + const [app] = apps; + return apps.length === 1 && app !== undefined + ? join(wrapper, app.name) + : undefined; +}; + +const resolveLayoutExecutable = async ( + bundle: string, + layout: BundleLayout, +): Promise> => { + const plistPath = join(bundle, ...layout.plist); + let name: string; + try { + const plist = await readFile(plistPath); + name = + plist.subarray(0, 6).toString("ascii") === "bplist" + ? await readBinaryPlistExecutable(plistPath) + : parseXmlPlistExecutable(plist.toString("utf8")); + } catch (cause: unknown) { + return err( + new BinaryTargetError(bundle, "app has no readable CFBundleExecutable", { + cause, + }), + ); + } + if (!isSafeExecutableName(name)) + return err( + new BinaryTargetError(bundle, "app has an unsafe CFBundleExecutable"), + ); + const programs = join(bundle, ...layout.programs); + const executable = join(programs, name); + try { + const [canonicalPrograms, canonicalExecutable] = await Promise.all([ + realpath(programs), + realpath(executable), + ]); + if (!isPathWithinRoot(canonicalPrograms, canonicalExecutable)) + return err( + new BinaryTargetError( + bundle, + `app program file leaves ${layout.programsLabel}`, + ), + ); + return ok({ executable: canonicalExecutable, infoPlist: plistPath }); + } catch (cause: unknown) { + return err( + new BinaryTargetError(bundle, "app program file is missing", { cause }), + ); + } +}; + +/** Decode the top-level executable name with an XML parser, not a pattern. */ +const parseXmlPlistExecutable = (plist: string): string => { + // An unrelated `__proto__` entry must not make the bundle unreadable. + const { value } = parseXmlPropertyList(plist); + const executable = executableEntrySchema.safeParse(value); + if (!executable.success) throw new Error("CFBundleExecutable is missing"); + return executable.data.CFBundleExecutable; +}; + +const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() }); + +const readBinaryPlistExecutable = async (plistPath: string): Promise => + // `-n` strips only the newline plutil appends; it requires macOS 12+. + ( + await execFileAsync("/usr/bin/plutil", [ + "-extract", + "CFBundleExecutable", + "raw", + "-n", + "-o", + "-", + plistPath, + ]) + ).stdout; + +const isSafeExecutableName = (name: string): boolean => + name.length > 0 && + name !== "." && + name !== ".." && + !name.includes("\0") && + !/[/\\]/u.test(name); diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 1b2fb8fdcf..938633e9b2 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -3,22 +3,19 @@ import { createHash } from "node:crypto"; import { access, open, - readFile, realpath, stat, type FileHandle, } from "node:fs/promises"; -import { execFile } from "node:child_process"; -import { extname, isAbsolute, join, resolve } from "node:path"; -import { promisify } from "node:util"; +import { extname, isAbsolute, resolve } from "node:path"; -import { z } from "zod"; - -import { isPathWithinRoot } from "../domain/localPath.js"; -import { parseXmlPropertyList } from "../domain/propertyListKeys.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; +import { + resolveAppBundleExecutable, + type ResolvedAppBundle, +} from "./AppBundleExecutable.js"; import { validateDosComLength, type ExecutableFormatHint, @@ -35,8 +32,6 @@ import { type ExecutableMetadata, } from "../domain/binaryTarget.js"; -const execFileAsync = promisify(execFile); - /** * Resolve and classify a readable local target before a provider is selected. * FAT Mach-O inputs select only a host-compatible architecture so setup remains @@ -55,7 +50,9 @@ export const parseBinaryTarget = async ( const canonical = await realpath(candidate); const resolved = await resolveAppBundle(canonical); if (!resolved.ok) return err(resolved.error); - const path = resolved.value; + const { executable: path, infoPlist } = resolved.value; + const bundle = + infoPlist === undefined ? {} : { bundleInfoPlist: infoPlist }; const handle = await open(path, "r"); try { if (!(await handle.stat()).isFile()) @@ -107,6 +104,7 @@ export const parseBinaryTarget = async ( return ok({ path, sourcePath: process.platform === "win32" ? candidate : canonical, + ...bundle, sha256: await sha256Handle(handle), kind: "executable", ...detected.value, @@ -216,89 +214,22 @@ const sha256Handle = async (handle: FileHandle): Promise => { const resolveAppBundle = async ( path: string, -): Promise> => { +): Promise> => { const metadata = await stat(path); - if (!metadata.isDirectory()) return ok(path); + if (!metadata.isDirectory()) return ok({ executable: path }); if (extname(path).toLowerCase() !== ".app") return err( new BinaryTargetError( path, - "this opening route requires a file or macOS app bundle", + "this opening route requires a file or app bundle", { constraint: "directory_requires_file", }, ), ); - const plistPath = join(path, "Contents", "Info.plist"); - let name: string; - try { - const plist = await readFile(plistPath); - name = - plist.subarray(0, 6).toString("ascii") === "bplist" - ? await readBinaryPlistExecutable(plistPath) - : parseXmlPlistExecutable(plist.toString("utf8")); - } catch (cause: unknown) { - return err( - new BinaryTargetError(path, "app has no readable CFBundleExecutable", { - cause, - }), - ); - } - if (!isSafeExecutableName(name)) - return err( - new BinaryTargetError(path, "app has an unsafe CFBundleExecutable"), - ); - const programs = join(path, "Contents", "MacOS"); - const executable = join(programs, name); - try { - const [canonicalPrograms, canonicalExecutable] = await Promise.all([ - realpath(programs), - realpath(executable), - ]); - if (!isPathWithinRoot(canonicalPrograms, canonicalExecutable)) - return err( - new BinaryTargetError(path, "app program file leaves Contents/MacOS"), - ); - return ok(canonicalExecutable); - } catch (cause: unknown) { - return err( - new BinaryTargetError(path, "app program file is missing", { cause }), - ); - } + return resolveAppBundleExecutable(path); }; -/** Decode the top-level executable name with an XML parser, not a pattern. */ -const parseXmlPlistExecutable = (plist: string): string => { - // An unrelated `__proto__` entry must not make the bundle unreadable. - const { value } = parseXmlPropertyList(plist); - const executable = executableEntrySchema.safeParse(value); - if (!executable.success) throw new Error("CFBundleExecutable is missing"); - return executable.data.CFBundleExecutable; -}; - -const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() }); - -const readBinaryPlistExecutable = async (plistPath: string): Promise => - // `-n` strips only the newline plutil appends; it requires macOS 12+. - ( - await execFileAsync("/usr/bin/plutil", [ - "-extract", - "CFBundleExecutable", - "raw", - "-n", - "-o", - "-", - plistPath, - ]) - ).stdout; - -const isSafeExecutableName = (name: string): boolean => - name.length > 0 && - name !== "." && - name !== ".." && - !name.includes("\0") && - !/[/\\]/u.test(name); - const readExecutableMetadata = async ( handle: FileHandle, hostArchitecture: NodeJS.Architecture, diff --git a/src/application/InterfaceBuilderAnalysis.ts b/src/application/InterfaceBuilderAnalysis.ts index 247a2f02c2..3dc03463f1 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 { omittedPrototypeKeysLimitation, parseXmlPropertyList, @@ -214,15 +215,17 @@ const projectNibArchive = (archive: NibArchiveDocument) => { const object = byId.get(objectId); if (object === undefined) return null; const storedClass = object.class_name.replace(/\0+$/u, ""); - return storedClass === "NSClassSwapper" - ? (referencedString(object.values.NSClassName) ?? storedClass) + if (storedClass === "NSClassSwapper") + return referencedString(object.values.NSClassName) ?? storedClass; + return storedClass === "UIClassSwapper" + ? (referencedString(object.values.UIClassName) ?? storedClass) : storedClass; }; const objects = Object.fromEntries( archive.objects .filter((object) => { const name = runtimeClass(object.id) ?? ""; - return !/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector)$/u.test( + return !/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector|UIRuntime\w*Connection)$/u.test( name, ); }) @@ -268,26 +271,34 @@ const projectNibArchive = (archive: NibArchiveDocument) => { }; for (const object of archive.objects) { const className = object.class_name.replace(/\0+$/u, ""); - const source = reference(object.values.NSSource); - const destination = reference(object.values.NSDestination); - if (className.includes("Connector") && source !== null) { - const label = referencedString(object.values.NSLabel); - const type = className.includes("Outlet") - ? "outlet" - : className.includes("Control") - ? "action" - : className; - // NSSource is the outlet owner or the sending control; NSDestination is - // the outlet value or the action target, and a nil target is the first - // responder. - (connections[String(source)] ??= []).push({ - type, - "destination-id": destination === null ? null : String(destination), - label, - source_id: String(source), - archive_object_id: String(object.id), - }); - } + // AppKit connectors use NS-prefixed keys; UIKit's runtime outlet and + // event connections use UI-prefixed keys with the same roles. + const uiKit = /^UIRuntime\w*Connection$/u.test(className); + if (!uiKit && !className.includes("Connector")) continue; + const values = object.values; + const source = reference(uiKit ? values.UISource : values.NSSource); + if (source === null) continue; + const destination = reference( + uiKit ? values.UIDestination : values.NSDestination, + ); + const label = referencedString(uiKit ? values.UILabel : values.NSLabel); + const type = className.includes("Outlet") + ? "outlet" + : className.includes("Control") || className.includes("Event") + ? "action" + : className; + const eventMask = values.UIEventMask; + // The source is the outlet owner or the sending control; the destination + // is the outlet value or the action target, and a nil target is the + // first responder. + (connections[String(source)] ??= []).push({ + type, + "destination-id": destination === null ? null : String(destination), + label, + source_id: String(source), + archive_object_id: String(object.id), + ...(typeof eventMask === "number" ? { ui_event_mask: eventMask } : {}), + }); } let hierarchyOmitted = 0; const hierarchyFor = ( @@ -390,6 +401,7 @@ const readEntry = async ( return Buffer.concat(chunks, length); }; +/** Project decoded plist data and dates while retaining omitted-key coverage. */ const decodePlist = ( bytes: Buffer, ): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => { @@ -397,5 +409,5 @@ const decodePlist = ( bytes.subarray(0, 8).toString("ascii") === "bplist00" ? { value: parseBinary(bytes), omittedPrototypeKeys: 0 } : parseXmlPropertyList(bytes.toString("utf8")); - return { value: jsonValueSchema.parse(value), omittedPrototypeKeys }; + return { value: projectPlistValue(value).value, omittedPrototypeKeys }; }; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 9083b13c14..edb9147066 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -4,6 +4,8 @@ export type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64"; interface BinaryTargetIdentity { readonly path: string; readonly sourcePath?: string; + /** Info.plist of the app bundle the target was resolved from. */ + readonly bundleInfoPlist?: string; readonly sha256: string; } diff --git a/src/domain/interfaceBuilderGraph.test.ts b/src/domain/interfaceBuilderGraph.test.ts index d88547bb6c..1fce2ab197 100644 --- a/src/domain/interfaceBuilderGraph.test.ts +++ b/src/domain/interfaceBuilderGraph.test.ts @@ -318,6 +318,91 @@ 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", + }); + }); +}); + describe("AppKit keyed archive connectors", () => { it("reads outlet and control connectors from NS-prefixed keys", () => { const parsed = parseInterfaceBuilderRecords({ diff --git a/src/domain/interfaceBuilderKeyedArchive.ts b/src/domain/interfaceBuilderKeyedArchive.ts index e0ca876ae4..602392c226 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; @@ -252,8 +320,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); @@ -327,7 +394,11 @@ const classifyObject = ( const value = className?.toLowerCase() ?? ""; if (value.includes("constraint")) return "constraint"; if (value.includes("layoutguide")) return "layout_guide"; - if (value.includes("placeholder") || value.includes("firstresponder")) + if ( + value.includes("placeholder") || + value.includes("firstresponder") || + value === "uiproxyobject" + ) return "placeholder"; if ( value.includes("image") || diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 7d365151d6..dd7c54802d 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -736,11 +736,10 @@ const resolvePlistPath = async ( return ok(resolve(requested)); const sourceMetadata = await stat(source); const root = sourceMetadata.isDirectory() ? source : dirname(target.path); - return ok( - requested === undefined - ? resolve(root, "Contents/Info.plist") - : resolve(root, requested), - ); + if (requested !== undefined) return ok(resolve(root, requested)); + // A bundle target records the Info.plist its program file was declared + // in: Contents/Info.plist, or the root plist of an iOS-style bundle. + return ok(target.bundleInfoPlist ?? resolve(root, "Contents/Info.plist")); } catch (cause: unknown) { return err( new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause }), diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 81f9126df9..9799045470 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; +import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -293,6 +293,122 @@ describe("app executable filename fidelity", () => { ); }); +describe("iOS-style app bundle targets", () => { + const plist = (name: string) => + `CFBundleExecutable${name}`; + + it("resolves a flat bundle's program file beside its Info.plist", async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Flat.app"); + await mkdir(app); + await writeFile(join(app, "Info.plist"), plist("Flat")); + await writeFile(join(app, "Flat"), thinMach(0xfeedfacf, 0x0100000c)); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(join(app, "Flat")), + bundleInfoPlist: join(await realpath(app), "Info.plist"), + format: "mach-o", + }); + }); + + it.skipIf(process.platform === "win32")( + "keeps the declaring Info.plist for a symlinked program file", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Linked.app"); + await mkdir(join(app, "bin"), { recursive: true }); + await writeFile(join(app, "Info.plist"), plist("Linked")); + await writeFile( + join(app, "bin", "real"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await symlink("bin/real", join(app, "Linked")); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(join(app, "bin", "real")), + bundleInfoPlist: join(await realpath(app), "Info.plist"), + }); + }, + ); + + it.skipIf(process.platform === "win32")( + "follows a readable Contents/Info.plist symlink", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Linked.app"); + const contents = join(app, "Contents"); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await writeFile(join(contents, "Real.plist"), plist("Linked")); + await symlink("Real.plist", join(contents, "Info.plist")); + await writeFile( + join(contents, "MacOS", "Linked"), + thinMach(0xfeedfacf, 0x0100000c), + ); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(join(contents, "MacOS", "Linked")), + }); + }, + ); + + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "reports a wrapper it cannot read as a permission denial", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Locked.app"); + const wrapper = join(app, "Wrapper"); + await mkdir(join(wrapper, "Locked.app"), { recursive: true }); + await chmod(wrapper, 0o000); + try { + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected a permission denial"); + expect(result.error.message).toContain("permission denied"); + } finally { + await chmod(wrapper, 0o755); + } + }, + ); + + it.skipIf(process.platform === "win32")( + "resolves an iOS-on-Mac wrapper through its single Wrapper bundle", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Phone.app"); + const wrapped = join(app, "Wrapper", "Phone.app"); + await mkdir(wrapped, { recursive: true }); + await writeFile(join(wrapped, "Info.plist"), plist("Phone")); + await writeFile(join(wrapped, "Phone"), thinMach(0xfeedfacf, 0x0100000c)); + await symlink("Wrapper/Phone.app", join(app, "WrappedBundle")); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(join(wrapped, "Phone")), + format: "mach-o", + }); + + await mkdir(join(app, "Wrapper", "Second.app")); + expect((await parseBinaryTarget(app, directory, "arm64")).ok).toBe(false); + }, + ); + + it.skipIf(process.platform === "win32")( + "rejects a flat bundle program symlink that leaves the bundle", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Escaping.app"); + const outside = join(directory, "outside"); + await mkdir(app); + await writeFile(outside, thinMach(0xfeedfacf, 0x0100000c)); + await writeFile(join(app, "Info.plist"), plist("Escaping")); + await symlink(outside, join(app, "Escaping")); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected an escaping program file"); + expect(result.error.message).toContain("leaves the bundle root"); + }, + ); +}); + describe("DOS binary target I/O", () => { it("validates a complete DOS file beyond the initial metadata probe", async () => { const directory = await createTestTempDirectory("rea-dos-target-"); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index a68503b3ab..df21730035 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -164,6 +164,50 @@ describe("compiled Interface Builder bundle reader", () => { }); }); +describe("flat Interface Builder plist values", () => { + 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"); + }); +}); + describe("bounded Interface Builder archive decoding", () => { it("counts malformed archives against the document limit", async () => { const root = await createTestTempDirectory("rea-ib-test-"); @@ -296,3 +340,92 @@ describe("bounded Interface Builder archive decoding", () => { }, ); }); + +describe("compiled UIKit NIB connections", () => { + it("projects UIKit runtime outlet and event connections", async () => { + const root = await createTestTempDirectory("rea-ib-test-"); + const bundle = join(root, "Example.app"); + await mkdir(bundle, { recursive: true }); + await writeFile( + join(bundle, "Cell.nib"), + encodeNibArchiveFixture({ + classes: [ + "UIProxyObject", + "UIClassSwapper", + "UIButton", + "UIRuntimeEventConnection", + "UIRuntimeOutletConnection", + "NSString", + ], + objects: [ + { classIndex: 0, values: { UIProxiedObjectIdentifier: { ref: 5 } } }, + { classIndex: 1, values: { UIClassName: { ref: 6 } } }, + { classIndex: 2, values: {} }, + { + classIndex: 3, + values: { + UISource: { ref: 2 }, + UIDestination: { ref: 1 }, + UILabel: { ref: 7 }, + UIEventMask: 64, + }, + }, + { + classIndex: 4, + values: { + UISource: { ref: 1 }, + UIDestination: { ref: 2 }, + UILabel: { ref: 8 }, + }, + }, + { classIndex: 5, values: { "NS.bytes": "IBFilesOwner" } }, + { classIndex: 5, values: { "NS.bytes": "BuildCell" } }, + { classIndex: 5, values: { "NS.bytes": "buildTapped:" } }, + { classIndex: 5, values: { "NS.bytes": "buildButton" } }, + ], + }), + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + const byId = new Map(analysis.graph.nodes.map((node) => [node.id, node])); + const named = (id: string | null) => + id === null ? null : (byId.get(id)?.name ?? null); + const action = analysis.graph.nodes.find(({ kind }) => kind === "action"); + expect(action).toMatchObject({ + name: "buildTapped:", + attributes: { ui_event_mask: 64 }, + }); + const actionEdges = analysis.graph.edges.filter( + ({ relation }) => relation === "target_action", + ); + expect( + actionEdges + .filter(({ to }) => to === action?.id) + .map(({ from }) => named(from)), + ).toEqual(["UIButton"]); + expect( + actionEdges + .filter(({ from }) => from === action?.id) + .map(({ to }) => named(to)), + ).toEqual(expect.arrayContaining(["BuildCell"])); + const outlet = analysis.graph.nodes.find(({ kind }) => kind === "outlet"); + expect(outlet?.name).toBe("buildButton"); + expect( + analysis.graph.edges + .filter( + ({ relation, from }) => + relation === "outlet_to" && from === outlet?.id, + ) + .map(({ to }) => named(to)), + ).toEqual(["UIButton"]); + expect( + analysis.graph.nodes.find(({ name }) => name === "IBFilesOwner")?.kind, + ).toBe("placeholder"); + expect(analysis.graph.nodes.map(({ name }) => name)).not.toContain( + "UIRuntimeEventConnection", + ); + }); +}); diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index fd265d0485..68eeee7b3a 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -278,6 +278,29 @@ describe("native macOS provider inspection", () => { }); }); +describe("native plist defaults for iOS-style bundles", () => { + it("defaults to the Info.plist the bundle program was resolved from", async () => { + directory = await createTestTempDirectory("rea-native-flat-"); + const app = join(directory, "Flat.app"); + const executable = join(app, "Flat"); + await mkdir(app, { recursive: true }); + await writeFile(executable, "fixture"); + await writeFile(join(app, "Info.plist"), "fixture"); + const client = new NativeMacOSProvider( + new FixtureRunner(), + "darwin", + ).createClient({ + ...machoTarget(executable, app), + bundleInfoPlist: join(app, "Info.plist"), + }); + + const plist = await client.execute("inspect_plist", {}); + expect(plist.ok && plist.value.result).toMatchObject({ + source_path: join(app, "Info.plist"), + }); + }); +}); + /** Emit entitlements whose dictionary also holds a legal `__proto__` key. */ class PrototypeEntitlementsRunner extends FixtureRunner { override async run(tool: string, arguments_: readonly string[]) {