import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { build, buildBinary } from "plist";
import { expect } from "vitest";
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const unknownMixedInteger = {
$plist_type: "unknown",
element: "integer",
literal: "90071992547409923",
reason: "unsupported-markup",
};
const scenarios = [
"XML",
"binary",
"integer collision",
"integer-real collision",
"real",
"CDATA real",
"hexadecimal",
"mixed XML",
"segmented keys",
] as const;
cliTest.for(scenarios)(
"preserves numeric meaning and graph identity for %s Interface Builder attributes",
async (scenario, { cli }) => {
const root = await createTestTempDirectory("rea-ib-plist-numbers-");
const app = join(root, "Numbers.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"),
scenario === "segmented keys"
? "CFBundleFixture"
: build({ CFBundleExecutable: "Fixture" }),
);
const source = {
$archiver: "NSKeyedArchiver",
$version: 100000,
$top: { root: { CF$UID: 1 } },
$objects: [
"$null",
{
$class: { CF$UID: 3 },
large: scenario === "binary" ? 9007199254740992 : "__INTEGER__",
safe: 42,
children: [{ CF$UID: 2 }],
...(scenario.includes("collision") ? { other: "__OTHER__" } : {}),
},
{ $class: { CF$UID: 3 }, label: "Child" },
{ $classname: "UIView", $classes: ["UIView", "NSObject"] },
],
};
let bytes =
scenario === "binary"
? Buffer.from(buildBinary(source))
: Buffer.from(
build(source)
.replace(
"__INTEGER__",
scenario === "real"
? "9007199254740992"
: scenario === "CDATA real"
? "9007199254"
: scenario === "hexadecimal"
? "0x20000000000001"
: scenario === "mixed XML"
? "90071992547409923"
: "9007199254740993",
)
.replace(
"__OTHER__",
scenario === "integer collision"
? "9007199254740992"
: "9007199254740992",
),
);
if (scenario === "binary") {
// The builder emits an unsafe JS number as a real. Replace that one
// 8-byte scalar with an exact signed integer of the same byte width.
const marker = Buffer.alloc(9);
marker[0] = 0x23;
marker.writeDoubleBE(9007199254740992, 1);
const offset = bytes.indexOf(marker);
expect(offset).toBeGreaterThan(0);
expect(bytes.indexOf(marker, offset + marker.length)).toBe(-1);
bytes[offset] = 0x13;
bytes.writeBigInt64BE(9007199254740993n, offset + 1);
}
if (scenario === "segmented keys")
bytes = Buffer.from(
bytes
.toString("utf8")
.replaceAll("$class", "$cl")
.replaceAll("children", "chil")
.replaceAll("safe", "sa"),
);
await writeFile(join(resources, "Main.nib"), bytes);
const output = await cli.run({
arguments: ["decode-interface-builder", app, "--json"],
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
});
expect(output.exitCode, output.stderr).toBe(0);
const result = interfaceBuilderAnalysisSchema.parse(
parseEvidence(output.json).normalized_result,
);
expect(result.documents).toEqual([
expect.objectContaining({
archive_sha256: createHash("sha256").update(bytes).digest("hex"),
object_count: 2,
}),
]);
expect(
result.graph.nodes.find(
({ attributes }) => attributes.interface_builder_object_id === "1",
),
).toMatchObject({
id: 'ib:"Contents/Resources/Main.nib":object:["1"]',
attributes: {
large:
scenario === "XML" ||
scenario === "binary" ||
scenario === "hexadecimal" ||
scenario === "segmented keys"
? { $plist_type: "integer", decimal: "9007199254740993" }
: scenario === "mixed XML"
? unknownMixedInteger
: 9007199254740992,
safe: 42,
children: [{ archiveUID: 2, className: "UIView" }],
interface_builder_object_id: "1",
class_name: "UIView",
},
});
expect(result.graph.nodes).toHaveLength(3);
expect(result.graph.edges).toContainEqual(
expect.objectContaining({
from: 'ib:"Contents/Resources/Main.nib":object:["1"]',
to: 'ib:"Contents/Resources/Main.nib":object:["2"]',
relation: "contains",
}),
);
if (
scenario === "XML" ||
scenario === "binary" ||
scenario === "hexadecimal" ||
scenario === "segmented keys"
)
expect(result.limitations).toContainEqual(
expect.stringContaining("1 unambiguous integer value"),
);
else if (scenario.includes("collision"))
expect(result.limitations).toContainEqual(
expect.stringContaining("integer precision loss remains possible"),
);
else if (scenario === "mixed XML")
expect(result.limitations).toContainEqual(
expect.stringContaining(
"1 XML scalar value(s) could not be established",
),
);
else
expect(
result.limitations.some((note) => note.includes("integer precision")),
).toBe(false);
expect(result.graph.truncated).toBe(false);
},
);