Files
reverse-engineering-anything/tests/acceptance/applications/interfaceBuilderPlistNumbers.test.ts
T
morluto 7aa4b98c98 fix(plist): own XML values and numeric facts in one decoder
Interpret the validated, depth-bounded DOM directly. Collect source number
facts during the same traversal and share them with keyed archives, Interface
Builder and native plist projections. Remove the second XML parse, key aliases,
source-location rewriting and both duplicate XML numeric scanners. Preserve
character references, container/null behavior, safe own-property construction
and prototype omission accounting.

Decode signed hexadecimal integers and combine real text/CDATA. Retain exact
integer identity when a finite integer exceeds even Number's finite range,
using the shared scalar projector. Keep unsupported mixed integer syntax
conservative and report incomplete metadata.

Adapt @apitoagents's hexadecimal regression and literal-parsing proposal from
PR #3475. Native plutil controls establish the supported hex/real cases and
confirm that the synthetic 401-digit overflow case exceeds native bounds;
retaining accepted input does not claim native-format support.

Validation: 274 focused checks in 30 files pass, one skipped; build,
check:fast and docs:check pass. Built CLI/actual stdio MCP, native plist and
Interface Builder controls cover kinds, references, exact digits and digests.
Eight baseline checks fail; native binary conversion independently verifies
updated CDATA real expectations. 48 tiny completed CLI cost comparisons
preserve ordinary XML/binary and all three native replies byte for byte.
Two read-only reviews clear. No statistical speed claim, heavy profiler,
investigated-target execution or real deep-provider verification.

Fixes #3373
Fixes #4042
Fixes #4789
2026-10-17 20:40:55 +08:00

173 lines
6.2 KiB
TypeScript

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 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"
? "<plist><dict><key>CFBundle<![CDATA[Executable]]></key><string>Fixture</string></dict></plist>"
: 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(
"<string>__INTEGER__</string>",
scenario === "real"
? "<real>9007199254740992</real>"
: scenario === "CDATA real"
? "<real>9007199254<![CDATA[740992]]></real>"
: scenario === "hexadecimal"
? "<integer>0x20000000000001</integer>"
: scenario === "mixed XML"
? "<integer>9007199254740992<!-- split -->3</integer>"
: "<integer>9007199254740993</integer>",
)
.replace(
"<string>__OTHER__</string>",
scenario === "integer collision"
? "<integer>9007199254740992</integer>"
: "<real>9007199254740992</real>",
),
);
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("<key>$class</key>", "<key>$cl<![CDATA[ass]]></key>")
.replaceAll("<key>children</key>", "<key>chil<![CDATA[dren]]></key>")
.replaceAll("<key>safe</key>", "<key>sa<![CDATA[fe]]></key>"),
);
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" }
: 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 !== "real" && scenario !== "CDATA real")
expect(result.limitations).toContainEqual(
expect.stringContaining("integer precision loss remains possible"),
);
else
expect(
result.limitations.some((note) => note.includes("integer precision")),
).toBe(false);
if (scenario === "mixed XML")
expect(result.limitations).toContainEqual(
expect.stringContaining("could not be completely observed"),
);
expect(result.graph.truncated).toBe(false);
},
);