mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
6fbe67d66e
Use original object-table UIDs for inventory, hierarchy and connector references. Retain authored identity fields as data, refuse forged reference aliases and select UIKit top-level collections without substituting unrelated roots. BREAKING CHANGE: Keyed archives that previously used authored objectID or object-id graph identities now use decimal table UIDs. Authored values remain attributes; ibtool and native NIBArchive identities retain their existing source semantics. Fixes #6849 Fixes #6850
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { createHash } from "node:crypto";
|
|
import { join } from "node:path";
|
|
|
|
import { build, buildBinary } from "plist";
|
|
import { expect } from "vitest";
|
|
|
|
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
|
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
|
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
|
import { cliTest } from "../../support/cli/cliFixture.js";
|
|
|
|
/** Write an app bundle whose only Interface Builder document is `Main.nib`. */
|
|
const writeUIKitApp = async (prefix: string, nib: string | Buffer) => {
|
|
const root = await createTestTempDirectory(prefix);
|
|
const app = join(root, "UIKitFixture.app");
|
|
const contents = join(app, "Contents");
|
|
const resources = join(contents, "Resources");
|
|
await mkdir(resources, { recursive: true });
|
|
await mkdir(join(contents, "MacOS"));
|
|
await writeFile(
|
|
join(contents, "MacOS", "Fixture"),
|
|
thinMach(0xfeedfacf, 0x0100000c),
|
|
);
|
|
await writeFile(
|
|
join(contents, "Info.plist"),
|
|
"<plist><dict><key>CFBundleExecutable</key><string>Fixture</string></dict></plist>",
|
|
);
|
|
await writeFile(join(resources, "Main.nib"), nib);
|
|
return app;
|
|
};
|
|
|
|
cliTest(
|
|
"decodes a rooted UIKit keyed-archive outlet through CLI",
|
|
async ({ cli }) => {
|
|
const app = await writeUIKitApp(
|
|
"rea-cli-uikit-outlet-",
|
|
await readFile(
|
|
join(process.cwd(), "tests/fixtures/interface-builder/UIKitOutlet.xml"),
|
|
),
|
|
);
|
|
|
|
const output = await cli.run({
|
|
arguments: ["decode-interface-builder", app, "--json"],
|
|
environment: { REA_LOG_LEVEL: "silent" },
|
|
});
|
|
expect(output.exitCode, output.stderr).toBe(0);
|
|
const result = interfaceBuilderAnalysisSchema.parse(
|
|
parseEvidence(output.json).normalized_result,
|
|
);
|
|
const owner = result.graph.nodes.find(
|
|
({ attributes }) => attributes.interface_builder_object_id === "1",
|
|
);
|
|
const destination = result.graph.nodes.find(
|
|
({ attributes }) => attributes.interface_builder_object_id === "2",
|
|
);
|
|
const outlet = result.graph.nodes.find(({ kind }) => kind === "outlet");
|
|
expect(result.documents).toEqual([
|
|
expect.objectContaining({ connection_count: 1 }),
|
|
]);
|
|
expect(outlet).toMatchObject({
|
|
id: 'ib:"Contents/Resources/Main.nib":outlet:["archive:3"]',
|
|
name: "view",
|
|
attributes: {
|
|
UISource: { archiveUID: 1 },
|
|
UIDestination: { archiveUID: 2 },
|
|
UILabel: "view",
|
|
},
|
|
});
|
|
expect(owner).toBeDefined();
|
|
expect(destination).toBeDefined();
|
|
expect(
|
|
result.graph.edges.some(
|
|
({ from, to, relation }) =>
|
|
from === owner?.id && to === outlet?.id && relation === "contains",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
result.graph.edges.some(
|
|
({ from, to, relation }) =>
|
|
from === outlet?.id &&
|
|
to === destination?.id &&
|
|
relation === "outlet_to",
|
|
),
|
|
).toBe(true);
|
|
expect(result.graph.coverage).toContainEqual(
|
|
expect.objectContaining({
|
|
facet: "connections:Contents/Resources/Main.nib",
|
|
status: "complete",
|
|
examined: 1,
|
|
omitted: 0,
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
cliTest.for(["xml", "binary"] as const)(
|
|
"preserves UID identity despite colliding authored IDs in %s through CLI and MCP",
|
|
async (format, { cli }) => {
|
|
// Source-derived keyed-nib records, not output from an Apple compiler.
|
|
const uid = (value: number) =>
|
|
format === "binary" ? { UID: value } : { CF$UID: value };
|
|
const source = {
|
|
$archiver: "NSKeyedArchiver",
|
|
$version: 100000,
|
|
$objects: [
|
|
"$null",
|
|
{
|
|
$class: uid(4),
|
|
id: "2",
|
|
archiveUID: 2,
|
|
objectID: uid(8),
|
|
"object-id": uid(9),
|
|
title: "Root",
|
|
NSSubviews: [uid(2)],
|
|
},
|
|
{
|
|
$class: uid(4),
|
|
objectID: uid(8),
|
|
"object-id": uid(9),
|
|
title: "Child",
|
|
},
|
|
{
|
|
$class: uid(5),
|
|
objectID: uid(8),
|
|
UISource: uid(2),
|
|
UIDestination: uid(1),
|
|
UILabel: "parent",
|
|
},
|
|
{
|
|
$classname: "ArchiveProbeView",
|
|
$classes: ["ArchiveProbeView", "UIView", "NSObject"],
|
|
},
|
|
{
|
|
$classname: "UIRuntimeOutletConnection",
|
|
$classes: [
|
|
"UIRuntimeOutletConnection",
|
|
"UIRuntimeConnection",
|
|
"NSObject",
|
|
],
|
|
},
|
|
{ $class: uid(7), "NS.objects": [uid(1)] },
|
|
{ $classname: "NSArray", $classes: ["NSArray", "NSObject"] },
|
|
"2",
|
|
"shared",
|
|
],
|
|
$top: { UINibTopLevelObjectsKey: uid(6) },
|
|
};
|
|
const bytes = Buffer.from(
|
|
format === "binary" ? buildBinary(source) : build(source),
|
|
);
|
|
const app = await writeUIKitApp("rea-keyed-nib-hierarchy-", bytes);
|
|
const path = join(app, "Contents/Resources/Main.nib");
|
|
const output = await cli.run({
|
|
arguments: ["decode-interface-builder", app, "--json"],
|
|
environment: { REA_LOG_LEVEL: "silent" },
|
|
});
|
|
expect(output.exitCode, output.stderr).toBe(0);
|
|
const evidence = parseEvidence(output.json);
|
|
const { call } = await connectLocalToolsMcp();
|
|
const opened = await call("open_bundle_resources", { path: app });
|
|
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
|
|
const reply = await call("decode_interface_builder", {});
|
|
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
|
|
expect(parseEvidence(reply.structuredContent).normalized_result).toEqual(
|
|
evidence.normalized_result,
|
|
);
|
|
const report = interfaceBuilderAnalysisSchema.parse(
|
|
evidence.normalized_result,
|
|
);
|
|
const nodes = new Map(report.graph.nodes.map((node) => [node.id, node]));
|
|
expect(
|
|
report.graph.edges
|
|
.filter(({ id }) => id.includes(":hierarchy:"))
|
|
.map(({ from, to, resolution }) => ({
|
|
from:
|
|
nodes.get(from)?.attributes.interface_builder_object_id ??
|
|
"document",
|
|
to:
|
|
to === null
|
|
? null
|
|
: nodes.get(to)?.attributes.interface_builder_object_id,
|
|
resolution,
|
|
})),
|
|
).toEqual([
|
|
{ from: "document", to: "1", resolution: "observed" },
|
|
{ from: "1", to: "2", resolution: "observed" },
|
|
]);
|
|
expect(
|
|
report.graph.nodes.find(
|
|
({ attributes }) => attributes.interface_builder_object_id === "1",
|
|
),
|
|
).toMatchObject({
|
|
attributes: {
|
|
id: "2",
|
|
archiveUID: 2,
|
|
objectID: "2",
|
|
"object-id": "shared",
|
|
title: "Root",
|
|
},
|
|
});
|
|
const root = report.graph.nodes.find(
|
|
({ attributes }) => attributes.interface_builder_object_id === "1",
|
|
);
|
|
const child = report.graph.nodes.find(
|
|
({ attributes }) => attributes.interface_builder_object_id === "2",
|
|
);
|
|
expect(child).toMatchObject({
|
|
name: "Child",
|
|
attributes: { objectID: "2", "object-id": "shared" },
|
|
});
|
|
const outlet = report.graph.nodes.find(({ kind }) => kind === "outlet");
|
|
expect(outlet).toMatchObject({
|
|
id: 'ib:"Contents/Resources/Main.nib":outlet:["archive:3"]',
|
|
});
|
|
expect(report.graph.edges).toContainEqual(
|
|
expect.objectContaining({
|
|
from: child?.id,
|
|
to: outlet?.id,
|
|
relation: "contains",
|
|
resolution: "observed",
|
|
}),
|
|
);
|
|
expect(report.graph.edges).toContainEqual(
|
|
expect.objectContaining({
|
|
from: outlet?.id,
|
|
to: root?.id,
|
|
relation: "outlet_to",
|
|
resolution: "observed",
|
|
}),
|
|
);
|
|
expect(report.documents[0]).toMatchObject({
|
|
archive_sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
connection_count: 1,
|
|
hierarchy_complete: true,
|
|
});
|
|
expect(report.graph.coverage).toContainEqual(
|
|
expect.objectContaining({
|
|
facet: "hierarchy:Contents/Resources/Main.nib",
|
|
status: "complete",
|
|
examined: 2,
|
|
omitted: 0,
|
|
}),
|
|
);
|
|
expect(report.graph.truncated).toBe(false);
|
|
expect(await readFile(path)).toEqual(bytes);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"classifies UIKit keyed-archive objects by their declared superclasses through CLI",
|
|
async ({ cli }) => {
|
|
// Class name and its archived `$classes` chain, with the expected kind.
|
|
// Neutral names classify only through their declared bases; controls also
|
|
// declare their view base. The constraint declares no framework base, so
|
|
// its name still classifies it.
|
|
const cases = [
|
|
["UINavigationController", ["UIViewController"], "view_controller"],
|
|
["UITabBarController", ["UIViewController"], "view_controller"],
|
|
["UISearchController", ["UIViewController"], "view_controller"],
|
|
["UIViewController", [], "view_controller"],
|
|
["SettingsScreen", ["UIViewController"], "view_controller"],
|
|
["InspectorPane", ["NSViewController"], "view_controller"],
|
|
["DocumentShell", ["NSWindowController"], "view_controller"],
|
|
["UIButton", ["UIControl", "UIView"], "control"],
|
|
["RatingPicker", ["UIControl", "UIView"], "control"],
|
|
["LevelGauge", ["NSControl", "NSView"], "control"],
|
|
["UIImageView", ["UIView"], "view"],
|
|
["CanvasSurface", ["NSView"], "view"],
|
|
["AppShell", ["UIWindow", "UIView"], "view"],
|
|
["Palette", ["NSWindow"], "view"],
|
|
["NSLayoutConstraint", [], "constraint"],
|
|
] as const;
|
|
const uid = (value: number) => ({ CF$UID: value });
|
|
const descriptorUid = (index: number) => cases.length + 1 + index;
|
|
const rootUid = cases.length * 2 + 1;
|
|
const nib = build({
|
|
$archiver: "NSKeyedArchiver",
|
|
$version: 100000,
|
|
$objects: [
|
|
"$null",
|
|
...cases.map((_, index) => ({ $class: uid(descriptorUid(index)) })),
|
|
...cases.map(([name, bases]) => ({
|
|
$classname: name,
|
|
$classes: [name, ...bases, "NSObject"],
|
|
})),
|
|
{
|
|
$class: uid(rootUid + 1),
|
|
"NS.objects": cases.map((_, index) => uid(index + 1)),
|
|
},
|
|
{ $classname: "NSArray", $classes: ["NSArray", "NSObject"] },
|
|
],
|
|
$top: { root: uid(rootUid) },
|
|
});
|
|
const app = await writeUIKitApp("rea-cli-uikit-kinds-", nib);
|
|
|
|
const output = await cli.run({
|
|
arguments: ["decode-interface-builder", app, "--json"],
|
|
environment: { REA_LOG_LEVEL: "silent" },
|
|
});
|
|
expect(output.exitCode, output.stderr).toBe(0);
|
|
const result = interfaceBuilderAnalysisSchema.parse(
|
|
parseEvidence(output.json).normalized_result,
|
|
);
|
|
const names = new Set<string>(cases.map(([name]) => name));
|
|
expect(
|
|
result.graph.nodes
|
|
.filter(({ attributes }) =>
|
|
names.has(String(attributes.class_name ?? "")),
|
|
)
|
|
.map(({ kind, attributes }) => [attributes.class_name, kind]),
|
|
).toEqual(cases.map(([name, , kind]) => [name, kind]));
|
|
expect(result.graph.coverage).toContainEqual(
|
|
expect.objectContaining({
|
|
facet: "objects:Contents/Resources/Main.nib",
|
|
status: "complete",
|
|
examined: cases.length + 1,
|
|
omitted: 0,
|
|
}),
|
|
);
|
|
},
|
|
);
|