fix(apple): account for repeated archive projections

This commit is contained in:
morluto 2026-10-09 05:53:00 +08:00
parent d575a7340b
commit e1df5e8555
7 changed files with 391 additions and 70 deletions

View File

@ -71,8 +71,12 @@ complete observation.
Before decoding, it also bounds the serialized structure: expanded binary
plist references, NIB table records, XML elements and text, plus their
projected representations must fit the 256 MiB aggregate decode budget.
This is a format-derived representation estimate, not a process-wide heap
ceiling.
NIB string values, data, property names and class names are charged at each
projected JSON occurrence, including when the archive reuses one source
value. Binary plist data projection also accounts for the temporary byte
copy made by the pinned decoder projection. These are format-derived
representation reservations, not exact heap measurements or a process-wide
heap ceiling.
Archives beyond either aggregate budget are omitted with their paths and an
explicitly partial `archive_decode` facet; previously decoded documents and
their identities remain available.

View File

@ -526,8 +526,9 @@ runtime or PC-98 device execution.
## Apple Interface Builder archives
`npm run verify:interface-builder` compiles the source-owned AppKit XIB into a
real `.nib` with Xcode `ibtool`, wraps it in a temporary app bundle, and checks
the decoded view hierarchy, outlet, action, evidence coverage, and truncation
real `.nib` with Xcode `ibtool`, wraps it in a temporary app bundle, and runs
the compiled CLI and stdio MCP server with production providers. It compares
their decoded results and checks the view hierarchy, outlet, action, evidence coverage, and truncation
status. Storyboard compilation additionally requires an installed iOS platform.
Keep the provider-specific acceptance path independent from optional

View File

@ -8,8 +8,11 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { parseBinaryTarget } from "../../../dist/application/BinaryTargetResolver.js";
import { ArtifactProvider } from "../../../dist/artifacts/ArtifactProvider.js";
import {
artifactCliEvidence,
artifactMcpResult,
withArtifactMcp,
} from "../../lib/artifact-e2e.mjs";
const exec = promisify(execFile);
const fixtureRoot = await mkdtemp(join(tmpdir(), "rea-interface-builder-"));
@ -41,14 +44,19 @@ try {
]);
await exec("cc", [join(sourceRoot, "fixture.c"), "-o", executablePath]);
const target = await parseBinaryTarget(appPath);
if (!target.ok) throw target.error;
const execution = await new ArtifactProvider()
.createClient(target.value)
.execute("decode_interface_builder", {}, {});
if (!execution.ok) throw execution.error;
const decoded = execution.value.result;
const evidence = await artifactCliEvidence(
"decode-interface-builder",
appPath,
);
const decoded = evidence.normalized_result;
await withArtifactMcp(appPath, async (client) => {
const mcp = await artifactMcpResult(client, "decode_interface_builder");
assert.deepEqual(
mcp,
decoded,
"CLI and MCP compiled archive results differ",
);
});
assert.equal(decoded.documents.length, 1);
assert.equal(
decoded.documents[0]?.relative_path,
@ -87,7 +95,9 @@ try {
report = {
ok: true,
fixture: "compiled-appkit-nib",
provider: execution.value.provider,
provider: evidence.provider,
public_cli: true,
public_stdio_mcp: true,
target_sha256: decoded.target_sha256,
archive_sha256: decoded.documents[0].archive_sha256,
objects: decoded.documents[0].object_count,

View File

@ -278,6 +278,14 @@ const projectNibArchive = (
maxHierarchyNodes: number,
) => {
const byId = new Map(archive.objects.map((object) => [object.id, object]));
const normalizedClassNames = new Map<string, string>();
const normalizeClassName = (value: string): string => {
const known = normalizedClassNames.get(value);
if (known !== undefined) return known;
const normalized = value.replace(/\0+$/u, "");
normalizedClassNames.set(value, normalized);
return normalized;
};
const decodedStrings = new Map<number, string | null>();
const decodedString = (
objectId: number,
@ -303,14 +311,6 @@ const projectNibArchive = (
decodedStrings.set(objectId, value);
return value;
};
const reserveStringOccurrence = (value: string | null): string | null => {
if (value !== null)
budget.reserve(
64 + value.length * 6,
"NIBArchive repeated string projection exceeds the aggregate Interface Builder decode budget",
);
return value;
};
let projectedFields = 0;
for (const object of archive.objects)
projectedFields += Object.keys(object.values).length;
@ -325,13 +325,21 @@ const projectNibArchive = (
const dereference = (value: JsonValue | undefined): JsonValue | undefined => {
if (typeof value !== "object" || value === null || Array.isArray(value))
return value;
const encodedData = value.$nib_data_base64;
if (typeof encodedData === "string") {
budget.reserveBase64JsonStringOccurrence(encodedData);
return value;
}
const ref = value.$nib_object_ref;
if (typeof ref !== "number") return value;
const target = byId.get(ref);
if (target === undefined) return undefined;
if (target.class_name.replace(/\0+$/u, "") === "NSString") {
if (normalizeClassName(target.class_name) === "NSString") {
const string = decodedString(ref, target);
if (string !== null) return reserveStringOccurrence(string);
if (string !== null) {
budget.reserveJsonStringOccurrence(string);
return string;
}
}
return { objectID: String(ref) };
};
@ -341,18 +349,31 @@ const projectNibArchive = (
const ref = value.$nib_object_ref;
if (typeof ref !== "number") return null;
const target = byId.get(ref);
if (target?.class_name.replace(/\0+$/u, "") !== "NSString") return null;
return reserveStringOccurrence(decodedString(ref, target));
if (
target === undefined ||
normalizeClassName(target.class_name) !== "NSString"
)
return null;
const string = decodedString(ref, target);
if (string === null) return null;
budget.reserveJsonStringOccurrence(string);
return string;
};
const runtimeClasses = new Map<number, string | null>();
const runtimeClass = (objectId: number): string | null => {
if (runtimeClasses.has(objectId))
return runtimeClasses.get(objectId) ?? null;
const object = byId.get(objectId);
if (object === undefined) return null;
const storedClass = object.class_name.replace(/\0+$/u, "");
if (storedClass === "NSClassSwapper")
return referencedString(object.values.NSClassName) ?? storedClass;
return storedClass === "UIClassSwapper"
? (referencedString(object.values.UIClassName) ?? storedClass)
: storedClass;
const storedClass = normalizeClassName(object.class_name);
const resolvedClass =
storedClass === "NSClassSwapper"
? (referencedString(object.values.NSClassName) ?? storedClass)
: storedClass === "UIClassSwapper"
? (referencedString(object.values.UIClassName) ?? storedClass)
: storedClass;
runtimeClasses.set(objectId, resolvedClass);
return resolvedClass;
};
const objects = Object.fromEntries(
archive.objects
@ -364,19 +385,22 @@ const projectNibArchive = (
})
.map((object) => {
const className =
runtimeClass(object.id) ?? object.class_name.replace(/\0+$/u, "");
const strings = Object.entries(object.values).flatMap(
([key, value]) => {
const resolved = dereference(value);
return typeof resolved === "string" ? [[key, resolved]] : [];
},
);
const name =
strings.find(([key]) =>
/(?:title|label|identifier|accessibility|name|contents)/iu.test(
key ?? "",
),
)?.[1] ?? className;
runtimeClass(object.id) ?? normalizeClassName(object.class_name);
budget.reserveJsonStringOccurrence(className);
let name = className;
for (const [key, value] of Object.entries(object.values)) {
if (
!/(?:title|label|identifier|accessibility|name|contents)/iu.test(
key,
)
)
continue;
const resolved = dereference(value);
if (typeof resolved !== "string") continue;
name = resolved;
break;
}
budget.reserveJsonStringOccurrence(name);
return [
String(object.id),
{
@ -384,10 +408,10 @@ const projectNibArchive = (
label: name,
objectID: String(object.id),
nibValues: Object.fromEntries(
Object.entries(object.values).map(([key, value]) => [
key,
dereference(value) ?? null,
]),
Object.entries(object.values).map(([key, value]) => {
budget.reserveJsonPropertyNameOccurrence(key);
return [key, dereference(value) ?? null];
}),
),
},
];
@ -403,7 +427,7 @@ const projectNibArchive = (
: null;
};
for (const object of archive.objects) {
const className = object.class_name.replace(/\0+$/u, "");
const className = normalizeClassName(object.class_name);
// AppKit connectors use NS-prefixed keys; UIKit's runtime outlet and
// event connections use UI-prefixed keys with the same roles.
const uiKit = /^UIRuntime\w*Connection$/u.test(className);
@ -420,6 +444,7 @@ const projectNibArchive = (
: className.includes("Control") || className.includes("Event")
? "action"
: className;
budget.reserveJsonStringOccurrence(type);
const eventMask = values.UIEventMask;
// The source is the outlet owner or the sending control; the destination
// is the outlet value or the action target, and a nil target is the
@ -485,7 +510,7 @@ const projectNibArchive = (
return { objectID: String(objectId), children };
};
const ibData = archive.objects.find(
({ class_name }) => class_name.replace(/\0+$/u, "") === "NSIBObjectData",
({ class_name }) => normalizeClassName(class_name) === "NSIBObjectData",
);
const rootId = reference(ibData?.values.NSRoot);
const hierarchyRoots = rootId === null ? [] : [rootId];
@ -512,7 +537,11 @@ const projectNibArchive = (
"com.apple.ibtool.document.connections": connections,
"com.apple.ibtool.document.hierarchy": merged.hierarchy,
"com.apple.ibtool.document.classes": Object.fromEntries(
archive.classes.map((name) => [name.replace(/\0+$/u, ""), {}]),
archive.classes.map((name) => {
const className = normalizeClassName(name);
budget.reserveJsonPropertyNameOccurrence(className);
return [className, {}];
}),
),
}),
};

View File

@ -48,6 +48,33 @@ export class InterfaceBuilderDecodeBudget {
this.#remainingBytes -= bytes;
}
/** Reserve an upper bound for one JSON-serialized string occurrence. */
reserveJsonStringOccurrence(value: string): void {
// JSON escaping uses at most six ASCII bytes per UTF-16 code unit, plus
// quotes. Count occurrences even when the source string is shared.
this.reserve(
value.length * 6 + 2,
"projected JSON strings exceed the aggregate Interface Builder decode budget",
);
}
/** Reserve the exact JSON string length for a base64 value from Buffer. */
reserveBase64JsonStringOccurrence(value: string): void {
// Buffer's base64 alphabet contains no JSON-escaped characters.
this.reserve(
value.length + 2,
"projected NIB base64 values exceed the aggregate Interface Builder decode budget",
);
}
/** Reserve an upper bound for one JSON object property name occurrence. */
reserveJsonPropertyNameOccurrence(value: string): void {
this.reserve(
value.length * 6 + 3,
"projected JSON property names exceed the aggregate Interface Builder decode budget",
);
}
/** Reserve bytes for an optional projection without throwing on exhaustion. */
tryReserve(bytes: number): boolean {
if (
@ -214,7 +241,7 @@ const estimateBinaryPlistExpansion = (
const byteLength = type === 6 ? size * 2 : size;
if (cursor + byteLength > offsetTable)
throw new TypeError("binary plist scalar object is truncated");
estimate += type === 4 ? Math.ceil((size * 4) / 3) : byteLength * 2;
estimate += type === 4 ? size + 4 * Math.ceil(size / 3) : byteLength * 2;
if (estimate > maxBytes)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",

View File

@ -395,7 +395,7 @@ const readValue = (
`value ${index} data`,
);
budget.reserve(
32 + Math.ceil((length * 4) / 3),
32 + 4 * Math.ceil(length / 3),
`NIBArchive data value ${index} exceeds the aggregate Interface Builder decode budget`,
);
const encoded = bytes.subarray(cursor.offset, next).toString("base64");

View File

@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest";
import { analyzeInterfaceBuilderBundle } from "../../../src/artifacts/apple/InterfaceBuilderAnalysis.js";
import { encodeNibArchiveFixture } from "../../../src/artifacts/apple/NibArchive.fixture.js";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
@ -96,6 +97,96 @@ const binaryPlistWithRepeatedReferences = (depth: number): Buffer => {
return Buffer.concat([header, ...encodedObjects, offsetTable, trailer]);
};
const binaryPlistWithRepeatedData = (
referenceCount: number,
dataLength: number,
): Buffer => {
const header = Buffer.from("bplist00", "ascii");
const array = Buffer.concat([
Buffer.from([0xaf, 0x10, referenceCount]),
Buffer.alloc(referenceCount, 1),
]);
const data = Buffer.concat([
Buffer.from([0x4f, 0x12]),
Buffer.from(
new Uint8Array([
(dataLength >>> 24) & 0xff,
(dataLength >>> 16) & 0xff,
(dataLength >>> 8) & 0xff,
dataLength & 0xff,
]),
),
Buffer.alloc(dataLength, 7),
]);
const offsets = Buffer.alloc(8);
offsets.writeUInt32BE(header.length, 0);
offsets.writeUInt32BE(header.length + array.length, 4);
const offsetTable = header.length + array.length + data.length;
const trailer = Buffer.alloc(32);
trailer[6] = 4;
trailer[7] = 1;
trailer.writeBigUInt64BE(2n, 8);
trailer.writeBigUInt64BE(0n, 16);
trailer.writeBigUInt64BE(BigInt(offsetTable), 24);
return Buffer.concat([header, array, data, offsets, trailer]);
};
const encodeNibArchiveWithSharedDataValue = (input: {
readonly className: string;
readonly fieldName: string;
readonly payload: string;
readonly objectCount: number;
}): Buffer => {
const varint = (value: number): Buffer => {
const bytes: number[] = [];
let remaining = value;
do {
let byte = remaining & 0x7f;
remaining >>>= 7;
if (remaining === 0) byte |= 0x80;
bytes.push(byte);
} while (remaining > 0);
return Buffer.from(bytes);
};
const objects = Buffer.concat(
Array.from({ length: input.objectCount }, () =>
Buffer.concat([varint(0), varint(0), varint(1)]),
),
);
const fieldBytes = Buffer.from(input.fieldName);
const keys = Buffer.concat([varint(fieldBytes.length), fieldBytes]);
const payloadBytes = Buffer.from(input.payload);
const values = Buffer.concat([
varint(0),
Buffer.from([8]),
varint(payloadBytes.length),
payloadBytes,
]);
const classBytes = Buffer.from(input.className);
const classes = Buffer.concat([
varint(classBytes.length),
varint(0),
classBytes,
]);
const objectsOffset = 50;
const keysOffset = objectsOffset + objects.length;
const valuesOffset = keysOffset + keys.length;
const classesOffset = valuesOffset + values.length;
const header = Buffer.alloc(50);
header.write("NIBArchive", 0, "ascii");
header.writeUInt32LE(1, 10);
header.writeUInt32LE(10, 14);
header.writeUInt32LE(input.objectCount, 18);
header.writeUInt32LE(objectsOffset, 22);
header.writeUInt32LE(1, 26);
header.writeUInt32LE(keysOffset, 30);
header.writeUInt32LE(1, 34);
header.writeUInt32LE(valuesOffset, 38);
header.writeUInt32LE(1, 42);
header.writeUInt32LE(classesOffset, 46);
return Buffer.concat([header, objects, keys, values, classes]);
};
describe("compiled Interface Builder bundle reader", () => {
it("reads nib plist archives and reports provenance", async () => {
const root = await createTestTempDirectory("rea-ib-test-");
@ -483,24 +574,16 @@ describe("bounded Interface Builder archive decoding", () => {
"RepeatedReferences.nib: omitted because aggregate decode budget exhausted.",
);
});
});
it("budgets repeated large NSString projections before retaining each field", async () => {
const root = await createTestTempDirectory("rea-ib-repeated-string-test-");
describe("binary plist data expansion budget", () => {
it("reserves copied binary plist data for every expanded reference", async () => {
const root = await createTestTempDirectory("rea-ib-binary-data-budget-");
const bundle = join(root, "Example.app");
await mkdir(bundle, { recursive: true });
const largeString = "x".repeat(1024 * 1024);
const references = Object.fromEntries(
Array.from({ length: 50 }, (_, index) => [`title${index}`, { ref: 1 }]),
);
await writeFile(
join(bundle, "RepeatedString.nib"),
encodeNibArchiveFixture({
classes: ["UIView", "NSString"],
objects: [
{ classIndex: 0, values: references },
{ classIndex: 1, values: { "NS.bytes": largeString } },
],
}),
join(bundle, "RepeatedData.nib"),
binaryPlistWithRepeatedData(200, 700 * 1024),
);
const result = await analyzeInterfaceBuilderBundle({
@ -519,11 +602,178 @@ describe("bounded Interface Builder archive decoding", () => {
}),
);
expect(result.limitations).toContain(
"RepeatedString.nib: omitted because aggregate decode budget exhausted.",
"RepeatedData.nib: omitted because aggregate decode budget exhausted.",
);
});
});
describe("NIB projection byte amplification", () => {
it("omits NIB projections whose shared strings or class names amplify beyond budget", async () => {
const repeatedString = "x".repeat(1024 * 1024);
const stringReferences = Object.fromEntries(
Array.from({ length: 50 }, (_, index) => [`title${index}`, { ref: 1 }]),
);
const longName = "n".repeat(512 * 1024);
const longNameObjects = Array.from({ length: 700 }, () => ({
classIndex: 0,
values: { field: true },
}));
const cases = [
{
name: "RepeatedString",
archive: encodeNibArchiveFixture({
classes: ["UIView", "NSString"],
objects: [
{ classIndex: 0, values: stringReferences },
{ classIndex: 1, values: { "NS.bytes": repeatedString } },
],
}),
},
{
name: "RepeatedClass",
archive: encodeNibArchiveFixture({
classes: [longName],
objects: longNameObjects,
}),
},
];
for (const { name, archive } of cases) {
const root = await createTestTempDirectory(`rea-ib-${name}-budget-`);
const bundle = join(root, "Example.app");
await mkdir(bundle, { recursive: true });
await writeFile(join(bundle, `${name}.nib`), archive);
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "f".repeat(64),
});
expect(result.documents, name).toEqual([]);
expect(result.graph.coverage, name).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "partial",
reason: "aggregate_decode_budget_exhausted",
examined: 1,
omitted: 1,
}),
);
expect(result.limitations, name).toContain(
`${name}.nib: omitted because aggregate decode budget exhausted.`,
);
}
});
it("retains a single large base64 data field within the representation budget", async () => {
const root = await createTestTempDirectory("rea-ib-large-data-positive-");
const bundle = join(root, "Example.app");
await mkdir(bundle, { recursive: true });
const archive = encodeNibArchiveFixture({
classes: ["UIView"],
objects: [
{
classIndex: 0,
values: { payload: "x".repeat(29 * 1024 * 1024) },
},
],
});
await writeFile(join(bundle, "LargePayload.nib"), archive);
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "f".repeat(64),
});
expect(result.documents.map(({ relative_path }) => relative_path)).toEqual([
"LargePayload.nib",
]);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({ facet: "archive_decode", status: "complete" }),
);
const serialized = JSON.stringify(result);
expect(serialized).toContain("$nib_data_base64");
expect(Buffer.byteLength(serialized)).toBeLessThan(256 * 1024 * 1024);
});
});
cliTest(
"compiled CLI preserves a good archive and reports repeated key and data omissions",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-ib-cli-amplification-");
const bundle = join(root, "Example.app");
const contents = join(bundle, "Contents");
const resources = join(bundle, "Contents", "Resources");
await mkdir(resources, { recursive: true });
await mkdir(join(contents, "MacOS"), { recursive: true });
await writeFile(
join(contents, "MacOS", "App"),
thinMach(0xfeedfacf, 0x0100000c),
);
await writeFile(
join(contents, "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
);
await writeFile(
join(resources, "A-Good.nib"),
encodeNibArchiveFixture({
classes: ["UIView"],
objects: [{ classIndex: 0, values: { title: "Good" } }],
}),
);
const longKey = "k".repeat(512 * 1024);
await writeFile(
join(resources, "B-RepeatedKey.nib"),
encodeNibArchiveFixture({
classes: ["UIView"],
objects: Array.from({ length: 700 }, () => ({
classIndex: 0,
values: { [longKey]: true },
})),
}),
);
await writeFile(
join(resources, "C-RepeatedData.nib"),
encodeNibArchiveWithSharedDataValue({
className: "UIView",
fieldName: "payload",
payload: "d".repeat(384 * 1024),
objectCount: 700,
}),
);
const output = await cli.run({
arguments: ["decode-interface-builder", bundle, "--json"],
environment: {
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
},
});
expect(output.exitCode, `${output.stderr}\n${output.stdout}`).toBe(0);
const result = interfaceBuilderAnalysisSchema.parse(
parseEvidence(output.json).normalized_result,
);
expect(result.documents.map(({ relative_path }) => relative_path)).toEqual([
"Contents/Resources/A-Good.nib",
]);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "partial",
reason: "aggregate_decode_budget_exhausted",
examined: 3,
omitted: 2,
}),
);
expect(result.limitations).toContain(
"Contents/Resources/B-RepeatedKey.nib: omitted because aggregate decode budget exhausted.",
);
expect(result.limitations).toContain(
"Contents/Resources/C-RepeatedData.nib: omitted because aggregate decode budget exhausted.",
);
},
);
describe("aggregate Interface Builder archive retention budget", () => {
it("keeps earlier documents when the aggregate decode reservation is exhausted", async () => {
const root = await createTestTempDirectory("rea-ib-budget-test-");