fix(apple): own ordered nib collections and registry parentage

This commit is contained in:
morluto
2026-10-24 03:15:04 +08:00
parent 9b7ba06661
commit 584791fedf
7 changed files with 380 additions and 36 deletions
+11 -4
View File
@@ -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 <app>` 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 <app-or-binary>` prefers a validated macOS Mach-O byte reader. It decodes
64-bit little-endian Objective-C class/metaclass records, superclass pointers,
+66 -13
View File
@@ -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<Record<string, JsonValue>>
>();
const projectedRepeatedValues = new Map<number, JsonValue>();
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<Record<string, JsonValue>>,
);
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);
+24 -15
View File
@@ -1,34 +1,43 @@
type NibFixtureValue =
| number
| string
| boolean
| null
| { ref: number }
| { float32: number }
| { float64: number };
type NibFixtureFields =
| Readonly<Record<string, NibFixtureValue>>
| 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),
+23
View File
@@ -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 }],
});
});
+56 -2
View File
@@ -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<Record<string, JsonValue>>;
readonly repeated_values?: Readonly<Record<string, readonly JsonValue[]>>;
}[];
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<string, JsonValue> = {};
const repeated: Record<string, JsonValue[]> = {};
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)
+47 -2
View File
@@ -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<number, number[]>();
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 };
};
@@ -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);
});