fix(artifacts): decode flat keyed-archive NIBs with data and shared references
decode_interface_builder failed on every flat (keyed-archive) NIB that holds plist data or dates. Archived colors keep their components as data, so this covers nearly every AppKit NIB: XLD.app decoded 0 of 64. The decoded Uint8Array and Date values failed JSON validation. They now use the shared plist projection, which keeps them as explicit $plist_type values. Decoding those archives exposed a second failure. The keyed-archive parser inlined every UID reference up to 16 levels for every object, so an archive whose views reference their window, superview, and next key view expanded exponentially. A 9 KB NIB took 3 s, and a 158 KB MainMenu.nib did not finish. A reference now resolves to its string, or to a stub naming the referenced object's archive UID, authored ID, and class. The hierarchy walk visits each object once. The parser also reads XML CF$UID references and treats UID 0 as nil. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
faaee693d5
commit
2a4667bb79
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, unknown>): 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<string, unknown> = {};
|
||||
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<number>();
|
||||
const node = (
|
||||
item: Record<string, unknown>,
|
||||
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<string, unknown>,
|
||||
): ReturnType<typeof parseInterfaceBuilderRecords> | 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<string, unknown> = {};
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
'<?xml version="1.0"?><plist version="1.0"><dict><key>$archiver</key><string>NSKeyedArchiver</string><key>$objects</key><array><string>$null</string><dict><key>NSWhite</key><data>MC41AA==</data><key>NSDate</key><date>2026-10-07T00:00:00Z</date></dict></array><key>$top</key><dict/></dict></plist>',
|
||||
);
|
||||
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue