fix(apple): preserve exact Interface Builder plist integers

Reuse byte-derived numeric metadata after graph classification so emitted
attributes preserve exact integers without changing UID links or identities.
Reserve metadata and attribute projection within the shared decode budget.

Validated six CLI cases covering XML, binary, collisions, real values and
incomplete metadata, plus decode-budget regressions. A small cold/warm CLI
comparison showed similar costs; it does not establish a speed improvement.
Refs #3876, #3972.

Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com>
This commit is contained in:
morluto
2026-10-16 05:17:59 +08:00
parent 0123d59768
commit 2202e3f0ba
6 changed files with 264 additions and 9 deletions
+12
View File
@@ -587,6 +587,18 @@ Exact and ambiguous value counts describe emitted root/object value instances;
a root and an object may contain the same value. Archive digests identify the
original bytes.
Interface Builder's XML and binary plist archives use the same number metadata
and exact-decimal representation for emitted node attributes. Projection follows
object and connection classification, so archive IDs, UID links and graph counts
keep their meaning. Precision limitations name the source document. Unsafe
integer-literal counts cover the archive, including fields not emitted as node
attributes; exact and ambiguous projection counts cover emitted attribute values.
Integral reals stay numbers, and ambiguous or incompletely observed values retain
explicit precision limitations. Before plist decoding, the existing aggregate
decode budget reserves estimated room for number metadata and attribute
projection; these estimates are not a hard process-memory bound. Native
NIBArchive decoding is unchanged by this plist-specific representation.
Dates are reported as `{ "$plist_type": "date", "iso8601": "<timestamp>" }`. A
date the decoder cannot represent, such as an XML date with a year beyond 9999
or a negative year, is reported as `{ "$plist_type": "date", "iso8601": null }`
@@ -36,6 +36,11 @@ import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
import {
binaryPlistNumberLiterals,
xmlPlistNumberLiterals,
} from "./PlistNumberLiterals.js";
import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js";
import {
hasBinaryPlistSignature,
hasNibArchiveSignature,
@@ -129,6 +134,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
bytes,
decodeBudget.remainingBytes,
xmlText,
{ numberProjection: true },
);
decodeBudget.reserve(
decodedReservation.estimatedBytes,
@@ -142,9 +148,15 @@ export const analyzeInterfaceBuilderBundle = async (input: {
value: raw,
omittedPrototypeKeys,
unknownDateCount,
numberLiterals,
} = nib === null
? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved)
: { value: nib.raw, omittedPrototypeKeys: 0, unknownDateCount: 0 };
: {
value: nib.raw,
omittedPrototypeKeys: 0,
unknownDateCount: 0,
numberLiterals: undefined,
};
if (nib !== null && nib.omitted > 0)
incompleteHierarchies.set(entry.path, nib.omitted);
if (nib !== null && nib.unresolvedConnections > 0)
@@ -161,6 +173,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
? "storyboard_scene"
: "nib",
raw,
...(numberLiterals === undefined ? {} : { numberLiterals }),
...(nib === null || nib.omitted === 0
? {}
: { omittedSerializedHierarchyReferences: nib.omitted }),
@@ -733,6 +746,7 @@ const decodePlist = (
readonly value: JsonValue;
readonly omittedPrototypeKeys: number | null;
readonly unknownDateCount: number;
readonly numberLiterals: PlistNumberLiterals;
} => {
const binary = hasBinaryPlistSignature(bytes);
const { value, omittedPrototypeKeys } = binary
@@ -746,5 +760,8 @@ const decodePlist = (
value: projected.value,
omittedPrototypeKeys,
unknownDateCount: projected.unknownDateCount,
numberLiterals: binary
? binaryPlistNumberLiterals(bytes)
: xmlPlistNumberLiterals(xmlText ?? decodeXmlPlistText(bytes)),
};
};
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { buildBinary } from "plist";
import { describe, expect, it } from "vitest";
import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
@@ -10,6 +11,34 @@ import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
const example = "<!DOCTYPE document [ harmless example ]>";
it.each([
[
"XML",
Buffer.from(
"<plist><array><integer>9007199254740993</integer></array></plist>",
),
],
["binary", Buffer.from(buildBinary([9007199254740992]))],
] as const)(
"reserves %s number metadata and value projection before decoding",
(_format, bytes) => {
const ordinary = preflightPropertyListDecode(
bytes,
1024 * 1024,
).estimatedBytes;
expect(() =>
preflightPropertyListDecode(bytes, ordinary, undefined, {
numberProjection: true,
}),
).toThrow(/budget/u);
expect(() =>
preflightPropertyListDecode(bytes, 1024 * 1024, undefined, {
numberProjection: true,
}),
).not.toThrow();
},
);
const archive = (content: string, comment = "") =>
`<?xml version="1.0"?><plist version="1.0">${comment}<dict><key>$archiver</key><string>NSKeyedArchiver</string><key>$objects</key><array><string>$null</string><dict><key>$class</key><dict><key>CF$UID</key><integer>2</integer></dict><key>title</key><string>${content}</string></dict><dict><key>$classname</key><string>NSButton</string><key>$classes</key><array><string>NSButton</string><string>NSObject</string></array></dict></array><key>$top</key><dict><key>root</key><dict><key>CF$UID</key><integer>1</integer></dict></dict></dict></plist>`;
@@ -102,11 +102,18 @@ export const preflightPropertyListDecode = (
bytes: Buffer,
maxBytes: number,
xmlText?: string,
options: Pick<BinaryPlistExpansionOptions, "numberProjection"> = {},
): PlistDecodePreflight => {
const { numberProjection = false } = options;
const expansion = hasBinaryPlistSignature(bytes)
? estimateBinaryPlistExpansion(bytes, maxBytes)
? estimateBinaryPlistExpansion(bytes, maxBytes, { numberProjection })
: {
estimatedBytes: estimateXmlPlistExpansion(bytes, maxBytes, xmlText),
estimatedBytes: estimateXmlPlistExpansion(
bytes,
maxBytes,
xmlText,
numberProjection,
),
binaryPrototypeKeyObserved: false,
};
if (expansion.estimatedBytes > maxBytes)
@@ -121,10 +128,13 @@ const estimateXmlPlistExpansion = (
bytes: Buffer,
maxBytes: number,
xmlText?: string,
numberProjection = false,
): number => {
const xml = xmlText ?? decodeXmlPlistText(bytes);
const decodedSourceBytes = xml.length * 2;
let elementCount = 0;
// Reserve room for numeric source metadata and the projected attribute copy.
const elementBytes = numberProjection ? 256 : 128;
// Value nesting is bounded by the shared plist parser, which counts plist
// value depth; raw element depth would also count the envelope and keys.
for (const token of xmlPreflightTokens(xml)) {
@@ -139,10 +149,10 @@ const estimateXmlPlistExpansion = (
continue;
}
elementCount += 1;
if (decodedSourceBytes * 2 + elementCount * 128 > maxBytes)
if (decodedSourceBytes * 2 + elementCount * elementBytes > maxBytes)
return maxBytes + 1;
}
return decodedSourceBytes * 2 + elementCount * 128;
return decodedSourceBytes * 2 + elementCount * elementBytes;
};
/** Consume each XML span once; malformed tokens cannot restart suffix scans. */
@@ -197,6 +207,8 @@ const interfaceBuilderBudgetFailure = (
/** How a caller names and reports binary plist expansion failures. */
export interface BinaryPlistExpansionOptions {
/** Include original number metadata and exact-integer attribute projection. */
readonly numberProjection?: boolean;
/** Budget named in failure messages. */
readonly budget?: string;
/** Error for budget, depth, and reference-cycle failures. */
@@ -229,6 +241,7 @@ export const estimateBinaryPlistExpansion = (
budget = "the aggregate Interface Builder decode budget",
fail = interfaceBuilderBudgetFailure,
bounds = "strict",
numberProjection = false,
}: BinaryPlistExpansionOptions = {},
): PlistDecodePreflight => {
if (bytes.length < 40)
@@ -261,7 +274,10 @@ export const estimateBinaryPlistExpansion = (
offsetTable + objectCount * offsetSize > tableEnd
)
throw new TypeError("binary plist trailer or object table is invalid");
let estimate = bytes.length * 2 + objectCount * 16;
// Metadata covers the full object table; projected values may repeat through references.
const objectBytes = numberProjection ? 144 : 16;
const expandedNodeBytes = numberProjection ? 224 : 96;
let estimate = bytes.length * 2 + objectCount * objectBytes;
let binaryPrototypeKeyObserved = false;
if (estimate > maxBytes)
throw fail("expansion", `binary plist object table exceeds ${budget}`);
@@ -286,7 +302,7 @@ export const estimateBinaryPlistExpansion = (
throw fail("depth", `binary plist nesting exceeds ${budget}`);
if (active.has(entry.object))
throw fail("cycle", `binary plist reference cycle exceeds ${budget}`);
estimate += 96;
estimate += expandedNodeBytes;
if (estimate > maxBytes)
throw fail(
"expansion",
@@ -352,7 +368,9 @@ export const estimateBinaryPlistExpansion = (
const refsEnd = cursor + childCount * referenceSize;
if (!Number.isSafeInteger(refsEnd) || refsEnd > objectsEnd)
throw new TypeError("binary plist reference table is truncated");
const availableNodes = Math.floor((maxBytes - estimate) / 96);
const availableNodes = Math.floor(
(maxBytes - estimate) / expandedNodeBytes,
);
if (childCount > availableNodes - pendingObjectCount)
throw fail(
"expansion",
+33 -1
View File
@@ -1,4 +1,8 @@
import { z } from "zod";
import {
createPlistNumberProjection,
type PlistNumberLiterals,
} from "./plistNumbers.js";
import {
firstString,
@@ -35,6 +39,8 @@ export interface InterfaceBuilderDocumentInput {
readonly archiveSha256: string;
readonly documentKind: "nib" | "storyboard_scene";
readonly raw: unknown;
/** Original plist number types, observed before lossy JavaScript decoding. */
readonly numberLiterals?: PlistNumberLiterals;
/** Serialized hierarchy links omitted before normalized graph projection. */
readonly omittedSerializedHierarchyReferences?: number;
}
@@ -59,6 +65,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
omitted: number;
}>;
const summaries: Array<z.infer<typeof interfaceBuilderDocumentSchema>> = [];
const numberLimitations: string[] = [];
let truncated = false;
let omittedObjects = 0;
let omittedConnections = 0;
@@ -66,6 +73,10 @@ export const buildInterfaceBuilderAnalysis = (input: {
let omittedHierarchyReferences = 0;
for (const document of input.documents.slice(0, input.limits.max_documents)) {
const parsed = parseInterfaceBuilderRecords(document.raw);
const numbers =
document.numberLiterals === undefined
? undefined
: createPlistNumberProjection(document.numberLiterals);
const prefix = `ib:${JSON.stringify(document.relativePath)}:`;
const graphId = (kind: string, parts: readonly unknown[] = []): string =>
`${prefix}${kind}:${JSON.stringify(parts)}`;
@@ -89,7 +100,22 @@ export const buildInterfaceBuilderAnalysis = (input: {
omittedGraphNodes += 1;
return false;
}
nodes.set(node.id, node);
// Resolve graph structure first; exact integer representation affects only
// serialized attributes, never object identity or UID-derived links.
nodes.set(
node.id,
numbers === undefined
? node
: {
...node,
attributes: Object.fromEntries(
Object.entries(node.attributes).map(([key, value]) => [
key,
numbers.project(value),
]),
),
},
);
return true;
};
const rootNode = {
@@ -443,6 +469,11 @@ export const buildInterfaceBuilderAnalysis = (input: {
: hierarchyTruncated
? "partial"
: "complete";
numberLimitations.push(
...(numbers?.limitations() ?? []).map(
(note) => `${document.relativePath}: ${note}`,
),
);
summaries.push({
relative_path: document.relativePath,
archive_sha256: document.archiveSha256,
@@ -562,6 +593,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
omittedHierarchyReferences > 0,
},
limitations: [
...numberLimitations,
"The graph reflects recognized Interface Builder archive fields and connections; omitted private or unrecognized archive fields remain unknown.",
"A target-action selector is an Interface Builder observation and does not prove that the binary contains a matching implementation.",
"Decoded constraints, runtime visibility, dynamic UI creation, and pixel geometry are not inferred from these archives.",
@@ -0,0 +1,147 @@
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",
"mixed XML",
] 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"),
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"] },
],
};
const bytes =
scenario === "binary"
? Buffer.from(buildBinary(source))
: Buffer.from(
build(source)
.replace(
"<string>__INTEGER__</string>",
scenario === "real"
? "<real>9007199254740992</real>"
: 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);
}
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"
? { $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")
expect(result.limitations).toContainEqual(
expect.stringContaining("1 unambiguous integer value"),
);
else if (scenario !== "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);
},
);