diff --git a/docs/native-investigation.md b/docs/native-investigation.md index cb54efe466..20f5beba05 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -454,10 +454,6 @@ complete observation. undecodable filenames and skipped symlinks, as partial coverage. Known executable roots remain available; unexamined names and symlink targets cannot establish an empty root set or the absence of other roots. - Embedded `UINib` archive data remains retained and undecoded; affected - object, connection, class and hierarchy coverage is partial. Recognized - `UIStoryboardShowSegueTemplate` source records similarly qualify connection - coverage until their navigation semantics are decoded. - `inspect_asset_catalog` / `rea inspect-asset-catalog ` reads compiled `Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw rendition fields, pagination and exact UI resource-name matches are returned. @@ -557,6 +553,17 @@ complete observation. coverage. Source I/O and identity failures retain their acquisition error instead of being labeled invalid archive content. Rejected plist decodes release their representation reservation for later documents. + Repeated NIBArchive fields retain all serialized values in order. Array + outlet connections resolve each serialized member and retain the collection + object ID and member position on the outlet observation. AppKit object-parent + registries use paired keys and parents; objects paired with `NSRoot` are + top-level, while nested objects keep their recorded parent. Conflicting or + unreadable registry pairs remain incomplete hierarchy evidence. + Embedded `UINib` archive data remains retained and undecoded; affected + object, connection, class and hierarchy coverage is partial. Recognized + `UIStoryboardShowSegueTemplate` source records similarly qualify connection + coverage until their navigation semantics are decoded. + - `inspect_native_dispatch_metadata` / `rea inspect-native-dispatch-metadata ` prefers a validated macOS Mach-O byte reader. It decodes 64-bit little-endian Objective-C class/metaclass records, superclass pointers, diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 64a561d611..3fa6dba956 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -24,7 +24,11 @@ import { omittedPrototypeKeysLimitation, parseXmlPropertyList, } from "../../domain/propertyListKeys.js"; -import { decodeNibArchive, type NibArchiveDocument } from "./NibArchive.js"; +import { + decodeNibArchive, + nibCollectionMembers, + type NibArchiveDocument, +} from "./NibArchive.js"; import { preflightPropertyListDecode } from "./PropertyListDecodeBudget.js"; import { interfaceBuilderDecodeBudgetFailure, @@ -633,7 +637,12 @@ const projectNibArchive = ( }; let projectedFields = 0; for (const object of archive.objects) - projectedFields += Object.keys(object.values).length; + projectedFields += + Object.keys(object.values).length + + Object.values(object.repeated_values ?? {}).reduce( + (count, values) => count + values.length, + 0, + ); budget.reserve( archive.objects.length * 128 + projectedFields * 128, "NIBArchive graph projection exceeds the aggregate Interface Builder decode budget", @@ -648,6 +657,7 @@ const projectNibArchive = ( number, Readonly> >(); + const projectedRepeatedValues = new Map(); for (const object of archive.objects) { const projected = projectPlistValue(object.values); nonFiniteRealCount += projected.nonFiniteRealCount; @@ -656,6 +666,12 @@ const projectNibArchive = ( object.id, projected.value as Readonly>, ); + if (object.repeated_values !== undefined) { + const repeated = projectPlistValue(object.repeated_values); + nonFiniteRealCount += repeated.nonFiniteRealCount; + negativeZeroRealCount += repeated.negativeZeroRealCount; + projectedRepeatedValues.set(object.id, repeated.value); + } } const dereference = (value: JsonValue | undefined): JsonValue | undefined => { if (typeof value !== "object" || value === null || Array.isArray(value)) @@ -714,7 +730,7 @@ const projectNibArchive = ( const objects = Object.fromEntries( archive.objects .filter((object) => { - const name = runtimeClass(object.id) ?? ""; + const name = normalizeClassName(object.class_name); return ( !/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector)$/u.test( name, @@ -761,6 +777,9 @@ const projectNibArchive = ( label: name, objectID: String(object.id), nibValues, + ...(projectedRepeatedValues.has(object.id) + ? { nibRepeatedValues: projectedRepeatedValues.get(object.id)! } + : {}), ...(className === normalizeClassName(object.class_name) ? {} : { @@ -790,7 +809,6 @@ const projectNibArchive = ( const className = normalizeClassName(object.class_name); // AppKit connectors use NS-prefixed keys; UIKit's runtime outlet and // event connections use UI-prefixed keys with the same roles. - const uiKit = isUIKitRuntimeConnectionClass(className); if (!isInterfaceBuilderConnectionClass(className)) continue; const values = object.values; const connectionFields = interfaceBuilderConnectionFieldNames(className); @@ -814,14 +832,49 @@ const projectNibArchive = ( // 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 } : {}), - }); + const members = + className === "UIRuntimeOutletCollectionConnection" + ? nibCollectionMembers( + destination === null ? undefined : byId.get(destination), + ) + : undefined; + if ( + className === "UIRuntimeOutletCollectionConnection" && + members === undefined + ) { + unresolvedConnections += 1; + continue; + } + if (members !== undefined && members.length === 0) { + unexaminedSources.push({ + reason: "empty_outlet_collection_not_projected", + facets: ["connections"], + description: `Outlet collection object ${object.id} has no serialized members; its raw collection endpoint is not projected as a relationship.`, + }); + continue; + } + const destinations = + members === undefined ? [destination] : members.map(reference); + for (const [memberIndex, member] of destinations.entries()) { + if (members !== undefined && member === null) { + unresolvedConnections += 1; + continue; + } + (connections[String(source)] ??= []).push({ + type, + "destination-id": member === null ? null : String(member), + label, + source_id: String(source), + archive_object_id: String(object.id), + ...(members === undefined + ? {} + : { + archive_collection_id: destination, + archive_collection_member_index: memberIndex, + }), + ...(typeof eventMask === "number" ? { ui_event_mask: eventMask } : {}), + }); + } } let hierarchyOmitted = 0; let hierarchyNodeCount = 0; @@ -861,7 +914,7 @@ const projectNibArchive = ( continue; } if (/array|set/iu.test(target.class_name)) { - for (const candidate of Object.values(target.values)) { + for (const candidate of nibCollectionMembers(target) ?? []) { const child = reference(candidate); const nested = child === null ? null : hierarchyFor(child, nextSeen, depth + 1); diff --git a/src/artifacts/apple/NibArchive.fixture.ts b/src/artifacts/apple/NibArchive.fixture.ts index 5eae14b783..5e412f6344 100644 --- a/src/artifacts/apple/NibArchive.fixture.ts +++ b/src/artifacts/apple/NibArchive.fixture.ts @@ -1,34 +1,43 @@ +type NibFixtureValue = + | number + | string + | boolean + | null + | { ref: number } + | { float32: number } + | { float64: number }; +type NibFixtureFields = + | Readonly> + | readonly (readonly [string, NibFixtureValue])[]; +const fieldEntries = ( + values: NibFixtureFields, +): readonly (readonly [string, NibFixtureValue])[] => + Array.isArray(values) ? values : Object.entries(values); + /** Encode a minimal NIBArchive object table for decoder and projection tests. */ export const encodeNibArchiveFixture = (input: { readonly classes: readonly string[]; readonly objects: readonly { readonly classIndex: number; - readonly values: Readonly< - Record< - string, - | number - | string - | boolean - | null - | { ref: number } - | { float32: number } - | { float64: number } - > - >; + readonly values: NibFixtureFields; }[]; }): Buffer => { const keys = [ - ...new Set(input.objects.flatMap(({ values }) => Object.keys(values))), + ...new Set( + input.objects.flatMap(({ values }) => + fieldEntries(values).map(([key]) => key), + ), + ), ]; const keyIndex = new Map(keys.map((key, index) => [key, index])); const valueRecords = input.objects.flatMap(({ values }) => - Object.entries(values).map(([key, value]) => + fieldEntries(values).map(([key, value]) => Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]), ), ); let valueStart = 0; const records = input.objects.map(({ classIndex, values }) => { - const count = Object.keys(values).length; + const count = fieldEntries(values).length; const result = Buffer.concat([ varint(classIndex), varint(valueStart), diff --git a/src/artifacts/apple/NibArchive.test.ts b/src/artifacts/apple/NibArchive.test.ts index a061acf247..d68c6b836c 100644 --- a/src/artifacts/apple/NibArchive.test.ts +++ b/src/artifacts/apple/NibArchive.test.ts @@ -184,3 +184,26 @@ describe("NIBArchive decoder", () => { ); }); }); + +it("retains repeated serialized collection fields in source order", () => { + const decoded = decodeNibArchive( + encodeArchive({ + classes: ["NSArray", "NSButton"], + objects: [ + { + classIndex: 0, + values: [ + ["UINibEncoderEmptyKey", { ref: 2 }], + ["UINibEncoderEmptyKey", { ref: 1 }], + ], + }, + { classIndex: 1, values: {} }, + { classIndex: 1, values: {} }, + ], + }), + ); + expect(decoded.objects[0]?.values).toEqual({}); + expect(decoded.objects[0]?.repeated_values).toEqual({ + UINibEncoderEmptyKey: [{ $nib_object_ref: 2 }, { $nib_object_ref: 1 }], + }); +}); diff --git a/src/artifacts/apple/NibArchive.ts b/src/artifacts/apple/NibArchive.ts index e975029909..8cf55a01e4 100644 --- a/src/artifacts/apple/NibArchive.ts +++ b/src/artifacts/apple/NibArchive.ts @@ -29,12 +29,37 @@ export interface NibArchiveDocument { readonly objects: readonly { readonly id: number; readonly class_name: string; + /** Single-valued fields. Repeated serialized keys are disjoint below. */ readonly values: Readonly>; + readonly repeated_values?: Readonly>; }[]; readonly classes: readonly string[]; readonly limitations: readonly string[]; } +/** Serialized collection members in source order; metadata fields are not members. */ +export const nibCollectionMembers = ( + object: NibArchiveDocument["objects"][number] | undefined, +): readonly JsonValue[] | undefined => { + if ( + object === undefined || + !/^(?:NS|NSMutable)(?:Array|Set)$/u.test( + object.class_name.replace(/\0+$/u, ""), + ) + ) + return undefined; + const keys = ["UINibEncoderEmptyKey", "NS.objects"]; + const populated = keys.filter( + (key) => + Object.hasOwn(object.values, key) || + Object.hasOwn(object.repeated_values ?? {}, key), + ); + if (populated.length > 1) return undefined; + const key = populated[0]; + if (key === undefined) return []; + return object.repeated_values?.[key] ?? [object.values[key]!]; +}; + /** Decode Apple's length-prefixed NIBArchive representation without loading UI classes. */ export const decodeNibArchive = ( bytes: Buffer, @@ -127,6 +152,7 @@ export const decodeNibArchive = ( const className = classes[object.class_index]; if (className === undefined) return []; const fields: Record = {}; + const repeated: Record = {}; for ( let index = object.value_start; index < object.value_start + object.value_count; @@ -135,7 +161,26 @@ export const decodeNibArchive = ( const value = values.get(index); if (value === undefined) continue; const key = keys[value.key_index]; - if (key !== undefined) + if (key === undefined) continue; + if (Object.hasOwn(repeated, key)) { + budget.reserve( + 16, + "NIBArchive repeated fields exceed the decode budget", + ); + repeated[key]!.push(value.value); + } else if (Object.hasOwn(fields, key)) { + budget.reserve( + 96, + "NIBArchive repeated fields exceed the decode budget", + ); + Object.defineProperty(repeated, key, { + value: [fields[key]!, value.value], + enumerable: true, + configurable: true, + writable: true, + }); + delete fields[key]; + } else // Archive keys are data, including the legacy __proto__ setter name. Object.defineProperty(fields, key, { value: value.value, @@ -144,7 +189,16 @@ export const decodeNibArchive = ( writable: true, }); } - return [{ id, class_name: className, values: fields }]; + return [ + { + id, + class_name: className, + values: fields, + ...(Object.keys(repeated).length === 0 + ? {} + : { repeated_values: repeated }), + }, + ]; }); let connectionObjectCount = 0; for (const object of objects) diff --git a/src/artifacts/apple/NibViewHierarchy.ts b/src/artifacts/apple/NibViewHierarchy.ts index 0d5aef0f6f..6b6741809f 100644 --- a/src/artifacts/apple/NibViewHierarchy.ts +++ b/src/artifacts/apple/NibViewHierarchy.ts @@ -1,4 +1,4 @@ -import type { NibArchiveDocument } from "./NibArchive.js"; +import { nibCollectionMembers, type NibArchiveDocument } from "./NibArchive.js"; import type { JsonValue } from "../../domain/jsonValue.js"; export type NibHierarchyNode = { @@ -17,7 +17,7 @@ export const projectNibViewHierarchy = ( objects, retainedIds, ); - if (parents.size === 0) return { hierarchy: null, omitted: invalid }; + if (involved.size === 0) return { hierarchy: null, omitted: invalid }; const children = new Map(); for (const [child, parent] of parents) { if (!retainedIds.has(parent) || !retainedIds.has(child)) continue; @@ -182,6 +182,51 @@ const collectNibParents = ( involved.add(object.id); if (retainedIds.has(parent)) involved.add(parent); } + const byId = new Map(objects.map((object) => [object.id, object])); + for (const object of objects) { + if (object.class_name.replace(/\0+$/u, "") !== "NSIBObjectData") continue; + const keysRef = reference(object.values.NSObjectsKeys); + const valuesRef = reference(object.values.NSObjectsValues); + if (keysRef === null && valuesRef === null) continue; + const keys = nibCollectionMembers( + keysRef === null ? undefined : byId.get(keysRef), + ); + const values = nibCollectionMembers( + valuesRef === null ? undefined : byId.get(valuesRef), + ); + if ( + keys === undefined || + values === undefined || + keys.length !== values.length + ) { + invalid += 1; + continue; + } + const rootOwner = reference(object.values.NSRoot); + for (let index = 0; index < keys.length; index += 1) { + const child = reference(keys[index]); + const parent = reference(values[index]); + if (child === null || parent === null) { + invalid += 1; + continue; + } + // Registry keys include non-UI records. Only retained objects contribute + // hierarchy, and a paired NSRoot owner identifies a top-level object. + if (!retainedIds.has(child)) continue; + involved.add(child); + const prior = parents.get(child); + if (parent === rootOwner) { + if (prior !== undefined) invalid += 1; + continue; + } + if (prior !== undefined && prior !== parent) { + invalid += 1; + continue; + } + parents.set(child, parent); + if (retainedIds.has(parent)) involved.add(parent); + } + } return { parents, involved, invalid }; }; diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 89d0597fce..fb8bdc5cb1 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -2232,3 +2232,156 @@ it.each(["embedded", "segue", "neither"] as const)( } }, ); + +it.each([1, 2])( + "projects every serialized outlet collection member in source order: %i", + async (count) => { + const bundle = await createTestTempDirectory("rea-ui-outlet-collection-"); + await writeFile( + join(bundle, "Main.nib"), + encodeNibArchiveFixture({ + classes: [ + "UIViewController", + "UIButton", + "UIRuntimeOutletCollectionConnection", + "NSMutableArray", + "NSString", + ], + objects: [ + { classIndex: 0, values: {} }, + { classIndex: 1, values: {} }, + { classIndex: 1, values: {} }, + { + classIndex: 2, + values: { + UISource: { ref: 0 }, + UIDestination: { ref: 4 }, + UILabel: { ref: 5 }, + }, + }, + { + classIndex: 3, + values: [ + ["UINibEncoderEmptyKey", { ref: 2 }], + ...(count === 2 + ? ([["UINibEncoderEmptyKey", { ref: 1 }]] as const) + : []), + ], + }, + { classIndex: 4, values: { "NS.bytes": "buttons" } }, + ], + }), + ); + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + }); + const edges = result.graph.edges.filter( + ({ relation }) => relation === "outlet_to", + ); + expect(edges).toHaveLength(count); + expect( + edges.every( + ({ resolution, reason }) => + resolution === "observed" && reason === null, + ), + ).toBe(true); + const outlets = result.graph.nodes.filter(({ kind }) => kind === "outlet"); + expect( + outlets.map(({ attributes }) => [ + attributes.archive_collection_id, + attributes.archive_collection_member_index, + ]), + ).toEqual(Array.from({ length: count }, (_, index) => [4, index])); + expect( + edges.map( + ({ to }) => + result.graph.nodes.find(({ id }) => id === to)?.attributes + .interface_builder_object_id, + ), + ).toEqual(count === 2 ? ["2", "1"] : ["2"]); + }, +); + +it("uses paired AppKit registry parents without promoting nested objects to roots", async () => { + const bundle = await createTestTempDirectory("rea-ui-parent-registry-"); + await writeFile( + join(bundle, "Main.nib"), + encodeNibArchiveFixture({ + classes: [ + "NSIBObjectData", + "NSCustomObject", + "NSView", + "NSArray", + "NSString", + ], + objects: [ + { + classIndex: 0, + values: { + NSRoot: { ref: 1 }, + NSObjectsKeys: { ref: 6 }, + NSObjectsValues: { ref: 7 }, + }, + }, + { classIndex: 1, values: {} }, + { classIndex: 1, values: { NSClassName: { ref: 8 } } }, + { classIndex: 1, values: { NSClassName: { ref: 9 } } }, + { classIndex: 2, values: {} }, + { classIndex: 2, values: { NSSuperview: { ref: 4 } } }, + { + classIndex: 3, + values: [ + ["UINibEncoderEmptyKey", { ref: 2 }], + ["UINibEncoderEmptyKey", { ref: 3 }], + ["UINibEncoderEmptyKey", { ref: 4 }], + ["UINibEncoderEmptyKey", { ref: 5 }], + ], + }, + { + classIndex: 3, + values: [ + ["UINibEncoderEmptyKey", { ref: 1 }], + ["UINibEncoderEmptyKey", { ref: 1 }], + ["UINibEncoderEmptyKey", { ref: 1 }], + ["UINibEncoderEmptyKey", { ref: 4 }], + ], + }, + { classIndex: 4, values: { "NS.bytes": "NSApplication" } }, + { classIndex: 4, values: { "NS.bytes": "VerifierHelper" } }, + ], + }), + ); + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + }); + const nodes = result.graph.nodes; + const nodeId = (object: string) => + nodes.find( + ({ attributes }) => attributes.interface_builder_object_id === object, + )?.id; + for (const object of ["2", "3", "4"]) + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + relation: "contains", + from: expect.stringMatching(/:root$/u), + to: nodeId(object), + }), + ); + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + relation: "contains", + from: nodeId("4"), + to: nodeId("5"), + }), + ); + expect(result.graph.edges).not.toContainEqual( + expect.objectContaining({ + relation: "contains", + from: expect.stringMatching(/:root$/u), + to: nodeId("5"), + }), + ); + expect(result.documents[0]?.hierarchy_complete).toBe(true); +});