fix(apple): budget Interface Builder decoding and projection

This commit is contained in:
morluto 2026-10-09 05:09:15 +08:00
parent f2e9c491ea
commit dbcf8c3391
7 changed files with 861 additions and 49 deletions

View File

@ -48,7 +48,16 @@ complete observation.
BOM and rejects malformed byte sequences, unsupported encoding declarations,
and declarations that disagree with the detected encoding. It never substitutes
UTF-8 for a declared encoding it cannot process. Archive and evidence digests
still identify the original serialized bytes.
still identify the original serialized bytes. Interface Builder decoding
limits aggregate input archives to 32 MiB.
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.
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.
- `inspect_native_dispatch_metadata` /
`rea inspect-native-dispatch-metadata <app-or-binary>` prefers a validated macOS Mach-O byte reader. It decodes
64-bit little-endian Objective-C class/metaclass records, superclass pointers,

View File

@ -8,6 +8,11 @@ import {
parseXmlPropertyList,
} from "../../domain/propertyListKeys.js";
import { decodeNibArchive, type NibArchiveDocument } from "./NibArchive.js";
import {
estimatePropertyListDecodeBytes,
InterfaceBuilderDecodeBudget,
InterfaceBuilderDecodeBudgetExceeded,
} from "./InterfaceBuilderDecodeBudget.js";
import type { ArtifactEntry } from "../ArtifactReader.js";
import {
buildInterfaceBuilderAnalysis,
@ -23,7 +28,9 @@ import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js";
import { decodeXmlPlistText } from "./XmlPropertyListText.js";
const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
const MAX_AGGREGATE_INPUT_BYTES = 32 * 1024 * 1024;
const MAX_AGGREGATE_DECODE_BYTES = 256 * 1024 * 1024;
const MAX_REPORTED_OMITTED_ARCHIVES = 64;
/** Decode compiled Interface Builder archives from a local app bundle. */
export const analyzeInterfaceBuilderBundle = async (input: {
@ -36,28 +43,84 @@ export const analyzeInterfaceBuilderBundle = async (input: {
const reader = new DirectoryArtifactReader(input.bundlePath);
const documents: InterfaceBuilderDocumentInput[] = [];
const invalid: string[] = [];
const omittedArchives: Array<{ path: string; reason: string }> = [];
let unreportedOmittedArchives = 0;
const incompleteHierarchies = new Map<string, number>();
const prototypeKeyOmissions = new Map<string, number>();
let omitted = 0;
let attempted = 0;
let candidates = 0;
let aggregateInputBytes = 0;
const decodeBudget = new InterfaceBuilderDecodeBudget(
MAX_AGGREGATE_DECODE_BYTES,
);
const recordOmittedArchive = (path: string, reason: string): void => {
if (omittedArchives.length < MAX_REPORTED_OMITTED_ARCHIVES)
omittedArchives.push({ path, reason });
else unreportedOmittedArchives += 1;
};
try {
for await (const entry of reader.entries(input.signal)) {
if (entry.kind !== "file" || !isInterfaceBuilderArchive(entry.path))
continue;
if (attempted >= limits.max_documents) {
candidates += 1;
if (candidates > limits.max_documents) {
omitted += 1;
recordOmittedArchive(entry.path, "max_documents");
continue;
}
attempted += 1;
try {
const bytes = await readEntry(reader, entry, input.signal);
const nib =
bytes.subarray(0, 10).toString("ascii") === "NIBArchive"
? projectNibArchive(decodeNibArchive(bytes))
: null;
const remainingInputBytes =
MAX_AGGREGATE_INPUT_BYTES - aggregateInputBytes;
if (
entry.declaredSize !== null &&
entry.declaredSize > remainingInputBytes
) {
omitted += 1;
recordOmittedArchive(entry.path, "aggregate_decode_budget_exhausted");
continue;
}
attempted += 1;
const bytes = await readEntry(
reader,
entry,
input.signal,
remainingInputBytes,
);
aggregateInputBytes += bytes.length;
const isNibArchive =
bytes.subarray(0, 10).toString("ascii") === "NIBArchive";
let nib: ReturnType<typeof projectNibArchive> | null = null;
let xmlText: string | undefined;
if (isNibArchive) {
const documentBudget = new InterfaceBuilderDecodeBudget(
decodeBudget.remainingBytes,
);
nib = projectNibArchive(
decodeNibArchive(bytes, documentBudget),
documentBudget,
limits.max_objects,
);
decodeBudget.reserve(
documentBudget.usedBytes,
"NIBArchive representation exceeds the aggregate Interface Builder decode budget",
);
} else {
if (bytes.subarray(0, 8).toString("ascii") !== "bplist00")
xmlText = decodeXmlPlistText(bytes);
const decodedReservation = estimatePropertyListDecodeBytes(
bytes,
decodeBudget.remainingBytes,
xmlText,
);
decodeBudget.reserve(
decodedReservation,
"plist representation exceeds the aggregate Interface Builder decode budget",
);
}
const { value: raw, omittedPrototypeKeys } =
nib === null
? decodePlist(bytes)
? decodePlist(bytes, xmlText)
: { value: nib.raw, omittedPrototypeKeys: 0 };
if (nib !== null && nib.omitted > 0)
incompleteHierarchies.set(entry.path, nib.omitted);
@ -74,6 +137,11 @@ export const analyzeInterfaceBuilderBundle = async (input: {
});
} catch (cause: unknown) {
if (input.signal?.aborted === true) throw cause;
if (cause instanceof InterfaceBuilderDecodeBudgetExceeded) {
omitted += 1;
recordOmittedArchive(entry.path, cause.reason);
continue;
}
invalid.push(
`${entry.path}: ${cause instanceof Error ? cause.message : "archive decode failed"}`,
);
@ -92,6 +160,8 @@ export const analyzeInterfaceBuilderBundle = async (input: {
incompleteHierarchies,
prototypeKeyOmissions,
invalid,
omittedArchives,
unreportedOmittedArchives,
attempted,
omitted,
});
@ -103,13 +173,23 @@ const finalizeHierarchyCoverage = (
incompleteHierarchies: ReadonlyMap<string, number>;
prototypeKeyOmissions: ReadonlyMap<string, number>;
invalid: readonly string[];
omittedArchives: readonly { path: string; reason: string }[];
unreportedOmittedArchives: number;
attempted: number;
omitted: number;
},
) => {
const { incompleteHierarchies, prototypeKeyOmissions, invalid } = counts;
const {
incompleteHierarchies,
prototypeKeyOmissions,
invalid,
omittedArchives,
unreportedOmittedArchives,
} = counts;
const archiveDecodePartial =
invalid.length > 0 || prototypeKeyOmissions.size > 0;
invalid.length > 0 ||
prototypeKeyOmissions.size > 0 ||
omittedArchives.length > 0;
return {
...result,
documents: result.documents.map((document) => ({
@ -139,12 +219,19 @@ const finalizeHierarchyCoverage = (
status: archiveDecodePartial
? ("partial" as const)
: ("complete" as const),
reason:
invalid.length > 0
? "one_or_more_archives_invalid"
: prototypeKeyOmissions.size > 0
? "dictionary_entries_omitted"
: null,
reason: omittedArchives.some(
({ reason }) => reason === "aggregate_decode_budget_exhausted",
)
? "aggregate_decode_budget_exhausted"
: omittedArchives.some(({ reason }) => reason === "max_documents")
? "document_limit_reached"
: omittedArchives.length > 0
? "archive_decode_budget_exhausted"
: invalid.length > 0
? "one_or_more_archives_invalid"
: prototypeKeyOmissions.size > 0
? "dictionary_entries_omitted"
: null,
examined: counts.attempted,
omitted: counts.omitted,
},
@ -171,13 +258,70 @@ const finalizeHierarchyCoverage = (
...[...prototypeKeyOmissions].map(
([path, count]) => `${path}: ${omittedPrototypeKeysLimitation(count)}`,
),
...omittedArchives.map(
({ path, reason }) =>
`${path}: omitted because ${reason.replaceAll("_", " ")}.`,
),
...(unreportedOmittedArchives === 0
? []
: [
`${unreportedOmittedArchives} additional omitted archive path(s) and their reasons were not retained; reporting is bounded to ${MAX_REPORTED_OMITTED_ARCHIVES} paths.`,
]),
],
};
};
/** Project decoded NIB records into the same bounded graph input as ibtool. */
const projectNibArchive = (archive: NibArchiveDocument) => {
const projectNibArchive = (
archive: NibArchiveDocument,
budget: InterfaceBuilderDecodeBudget,
maxHierarchyNodes: number,
) => {
const byId = new Map(archive.objects.map((object) => [object.id, object]));
const decodedStrings = new Map<number, string | null>();
const decodedString = (
objectId: number,
object: NibArchiveDocument["objects"][number],
): string | null => {
if (decodedStrings.has(objectId))
return decodedStrings.get(objectId) ?? null;
const data = object.values["NS.bytes"];
if (typeof data !== "object" || data === null || Array.isArray(data)) {
decodedStrings.set(objectId, null);
return null;
}
const encoded = data.$nib_data_base64;
if (typeof encoded !== "string") {
decodedStrings.set(objectId, null);
return null;
}
budget.reserve(
64 + encoded.length * 2,
"NIBArchive string projection exceeds the aggregate Interface Builder decode budget",
);
const value = Buffer.from(encoded, "base64").toString("utf8");
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;
budget.reserve(
archive.objects.length * 128 + projectedFields * 128,
"NIBArchive graph projection exceeds the aggregate Interface Builder decode budget",
);
budget.reserve(
archive.classes.length * 96,
"NIBArchive class projection exceeds the aggregate Interface Builder decode budget",
);
const dereference = (value: JsonValue | undefined): JsonValue | undefined => {
if (typeof value !== "object" || value === null || Array.isArray(value))
return value;
@ -186,12 +330,8 @@ const projectNibArchive = (archive: NibArchiveDocument) => {
const target = byId.get(ref);
if (target === undefined) return undefined;
if (target.class_name.replace(/\0+$/u, "") === "NSString") {
const data = target.values["NS.bytes"];
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
const encoded = data.$nib_data_base64;
if (typeof encoded === "string")
return Buffer.from(encoded, "base64").toString("utf8");
}
const string = decodedString(ref, target);
if (string !== null) return reserveStringOccurrence(string);
}
return { objectID: String(ref) };
};
@ -201,14 +341,8 @@ const projectNibArchive = (archive: NibArchiveDocument) => {
const ref = value.$nib_object_ref;
if (typeof ref !== "number") return null;
const target = byId.get(ref);
const data = target?.values["NS.bytes"];
if (target?.class_name.replace(/\0+$/u, "") !== "NSString") return null;
if (typeof data !== "object" || data === null || Array.isArray(data))
return null;
const encoded = data.$nib_data_base64;
return typeof encoded === "string"
? Buffer.from(encoded, "base64").toString("utf8")
: null;
return reserveStringOccurrence(decodedString(ref, target));
};
const runtimeClass = (objectId: number): string | null => {
const object = byId.get(objectId);
@ -300,6 +434,7 @@ const projectNibArchive = (archive: NibArchiveDocument) => {
});
}
let hierarchyOmitted = 0;
let hierarchyNodeCount = 0;
const hierarchyFor = (
objectId: number,
seen: Set<number>,
@ -309,11 +444,20 @@ const projectNibArchive = (archive: NibArchiveDocument) => {
hierarchyOmitted += 1;
return null;
}
if (hierarchyNodeCount >= maxHierarchyNodes) {
hierarchyOmitted += 1;
return null;
}
if (!budget.tryReserve(128)) {
hierarchyOmitted += 1;
return null;
}
const object = byId.get(objectId);
if (object === undefined) {
hierarchyOmitted += 1;
return null;
}
hierarchyNodeCount += 1;
const nextSeen = new Set(seen).add(objectId);
const children: NibHierarchyNode[] = [];
for (const [key, value] of Object.entries(object.values)) {
@ -352,8 +496,15 @@ const projectNibArchive = (archive: NibArchiveDocument) => {
const viewHierarchy = projectNibViewHierarchy(
archive.objects,
new Set(Object.keys(objects).map(Number)),
() => budget.tryReserve(128),
maxHierarchyNodes,
);
const merged = mergeNibHierarchies(
hierarchy,
viewHierarchy.hierarchy ?? [],
() => budget.tryReserve(96),
maxHierarchyNodes,
);
const merged = mergeNibHierarchies(hierarchy, viewHierarchy.hierarchy ?? []);
return {
omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted,
raw: jsonValueSchema.parse({
@ -376,10 +527,12 @@ const readEntry = async (
reader: DirectoryArtifactReader,
entry: ArtifactEntry,
signal?: AbortSignal,
maxInputBytes = MAX_AGGREGATE_INPUT_BYTES,
): Promise<Buffer> => {
if (entry.declaredSize !== null && entry.declaredSize > MAX_DOCUMENT_BYTES)
throw new RangeError(
"archive exceeds the 64 MiB per-document decode limit",
if (entry.declaredSize !== null && entry.declaredSize > maxInputBytes)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"archive exceeds the aggregate Interface Builder decode budget",
);
const stream = await reader.open(entry, signal);
const chunks: Buffer[] = [];
@ -389,10 +542,11 @@ const readEntry = async (
? chunk
: Buffer.from(chunk as Uint8Array);
length += bytes.length;
if (length > MAX_DOCUMENT_BYTES) {
if (length > maxInputBytes) {
stream.destroy();
throw new RangeError(
"archive exceeded the 64 MiB per-document decode limit",
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"archive exceeded the aggregate Interface Builder decode budget",
);
}
chunks.push(bytes);
@ -403,10 +557,11 @@ const readEntry = async (
/** Project decoded plist data and dates while retaining omitted-key coverage. */
const decodePlist = (
bytes: Buffer,
xmlText?: string,
): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => {
const { value, omittedPrototypeKeys } =
bytes.subarray(0, 8).toString("ascii") === "bplist00"
? { value: parseBinary(bytes), omittedPrototypeKeys: 0 }
: parseXmlPropertyList(decodeXmlPlistText(bytes));
: parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes));
return { value: projectPlistValue(value).value, omittedPrototypeKeys };
};

View File

@ -0,0 +1,250 @@
import { decodeXmlPlistText } from "./XmlPropertyListText.js";
/** Reason reported when an archive would exceed the shared decoding budget. */
export type InterfaceBuilderBudgetReason = "aggregate_decode_budget_exhausted";
/** A named resource-limit failure that keeps budget exhaustion out of decode errors. */
export class InterfaceBuilderDecodeBudgetExceeded extends Error {
constructor(
readonly reason: InterfaceBuilderBudgetReason,
message: string,
) {
super(message);
this.name = "InterfaceBuilderDecodeBudgetExceeded";
}
}
/** Shared byte budget for decoder records and projected archive data. */
export class InterfaceBuilderDecodeBudget {
readonly #initialBytes: number;
#remainingBytes: number;
constructor(maxBytes: number) {
this.#initialBytes = maxBytes;
this.#remainingBytes = maxBytes;
}
/** Representation bytes still available for the current inspection. */
get remainingBytes(): number {
return this.#remainingBytes;
}
/** Representation bytes reserved so far. */
get usedBytes(): number {
return this.#initialBytes - this.#remainingBytes;
}
/** Reserve representation bytes before allocation, or report exhaustion. */
reserve(bytes: number, message: string): void {
if (
!Number.isSafeInteger(bytes) ||
bytes < 0 ||
bytes > this.#remainingBytes
)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
message,
);
this.#remainingBytes -= bytes;
}
/** Reserve bytes for an optional projection without throwing on exhaustion. */
tryReserve(bytes: number): boolean {
if (
!Number.isSafeInteger(bytes) ||
bytes < 0 ||
bytes > this.#remainingBytes
)
return false;
this.#remainingBytes -= bytes;
return true;
}
}
/** Preflight the representation created by the pinned plist decoder. */
export const estimatePropertyListDecodeBytes = (
bytes: Buffer,
maxBytes: number,
xmlText?: string,
): number => {
const estimate =
bytes.subarray(0, 8).toString("ascii") === "bplist00"
? estimateBinaryPlistExpansion(bytes, maxBytes)
: estimateXmlPlistExpansion(bytes, maxBytes, xmlText);
if (estimate > maxBytes)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"plist representation exceeds the aggregate Interface Builder decode budget",
);
return estimate;
};
const estimateXmlPlistExpansion = (
bytes: Buffer,
maxBytes: number,
xmlText?: string,
): number => {
const xml = xmlText ?? decodeXmlPlistText(bytes);
if (/<!DOCTYPE\b[^>]*\[/iu.test(xml))
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"XML plist internal entity expansion exceeds the aggregate Interface Builder decode budget",
);
const elementPattern =
/<!--[^]*?-->|<!\[CDATA\[[^]*?\]\]>|<!DOCTYPE[^>]*>|<\?[^]*?\?>|<\/?[A-Za-z][^>]*>/giu;
const decodedSourceBytes = xml.length * 2;
let elementCount = 0;
let xmlDepth = 0;
for (const match of xml.matchAll(elementPattern)) {
const token = match[0] ?? "";
if (
token.startsWith("<!--") ||
token.startsWith("<![CDATA[") ||
token.startsWith("<!DOCTYPE") ||
token.startsWith("<?")
)
continue;
elementCount += 1;
if (token.startsWith("</")) xmlDepth -= 1;
else if (!token.endsWith("/>")) {
xmlDepth += 1;
if (xmlDepth > 128)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"XML plist nesting exceeds the aggregate Interface Builder decode budget",
);
}
if (decodedSourceBytes * 2 + elementCount * 128 > maxBytes)
return maxBytes + 1;
}
return decodedSourceBytes * 2 + elementCount * 128;
};
/** Count every expanded binary-plist reference before calling plist.parseBinary. */
const estimateBinaryPlistExpansion = (
bytes: Buffer,
maxBytes: number,
): number => {
if (bytes.length < 40)
throw new TypeError("binary plist trailer is truncated");
const trailer = bytes.length - 32;
const offsetSize = bytes[trailer + 6] ?? 0;
const referenceSize = bytes[trailer + 7] ?? 0;
if (
![1, 2, 4, 8].includes(offsetSize) ||
![1, 2, 4, 8].includes(referenceSize)
)
throw new TypeError("binary plist trailer integer sizes are invalid");
const readInteger = (offset: number, size: number): number => {
if (offset < 0 || size < 1 || offset + size > bytes.length) return -1;
let value = 0;
for (let index = 0; index < size; index += 1)
value = value * 256 + (bytes[offset + index] ?? 0);
return Number.isSafeInteger(value) ? value : -1;
};
const objectCount = readInteger(trailer + 8, 8);
const topObject = readInteger(trailer + 16, 8);
const offsetTable = readInteger(trailer + 24, 8);
if (
objectCount < 1 ||
topObject < 0 ||
topObject >= objectCount ||
offsetTable < 8 ||
offsetTable + objectCount * offsetSize > trailer
)
throw new TypeError("binary plist trailer or object table is invalid");
let estimate = bytes.length * 2 + objectCount * 16;
if (estimate > maxBytes)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"binary plist object table exceeds the aggregate Interface Builder decode budget",
);
const active = new Set<number>();
const pending: Array<{ object: number; depth: number; exit: boolean }> = [
{ object: topObject, depth: 0, exit: false },
];
let pendingObjectCount = 1;
while (pending.length > 0) {
const entry = pending.pop();
if (entry === undefined) break;
if (entry.exit) {
active.delete(entry.object);
continue;
}
pendingObjectCount -= 1;
if (entry.depth > 128)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"binary plist nesting exceeds the aggregate Interface Builder decode budget",
);
if (active.has(entry.object))
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"binary plist reference cycle exceeds the aggregate Interface Builder decode budget",
);
estimate += 96;
if (estimate > maxBytes)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"binary plist reference expansion exceeds the aggregate Interface Builder decode budget",
);
const offset = readInteger(
offsetTable + entry.object * offsetSize,
offsetSize,
);
if (offset < 8 || offset >= offsetTable)
throw new TypeError("binary plist object offset is invalid");
const marker = bytes[offset] ?? 0;
const type = marker >> 4;
let size = marker & 0x0f;
let cursor = offset + 1;
if (size === 0x0f && type !== 0 && type !== 8) {
const extMarker = bytes[cursor] ?? 0;
if (extMarker >> 4 !== 1 || (extMarker & 0x0f) > 3)
throw new TypeError("binary plist extended object size is invalid");
cursor += 1;
const integerSize = 1 << (extMarker & 0x0f);
size = readInteger(cursor, integerSize);
cursor += integerSize;
if (size < 0 || cursor > offsetTable)
throw new TypeError("binary plist extended object size is invalid");
}
if (type === 4 || type === 5 || type === 6) {
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;
if (estimate > maxBytes)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"binary plist scalar expansion exceeds the aggregate Interface Builder decode budget",
);
continue;
}
if (type !== 10 && type !== 13) continue;
const childCount = type === 13 ? size * 2 : size;
const refsEnd = cursor + childCount * referenceSize;
if (!Number.isSafeInteger(refsEnd) || refsEnd > offsetTable)
throw new TypeError("binary plist reference table is truncated");
const availableNodes = Math.floor((maxBytes - estimate) / 96);
if (childCount > availableNodes - pendingObjectCount)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"binary plist reference expansion exceeds the aggregate Interface Builder decode budget",
);
active.add(entry.object);
pending.push({ object: entry.object, depth: entry.depth, exit: true });
for (let index = childCount - 1; index >= 0; index -= 1) {
const reference = readInteger(
cursor + index * referenceSize,
referenceSize,
);
if (reference < 0 || reference >= objectCount)
throw new TypeError("binary plist reference is invalid");
pending.push({ object: reference, depth: entry.depth + 1, exit: false });
}
pendingObjectCount += childCount;
}
return estimate;
};

View File

@ -2,6 +2,38 @@ import { describe, expect, it } from "vitest";
import { decodeNibArchive } from "./NibArchive.js";
import { encodeNibArchiveFixture as encodeArchive } from "./NibArchive.fixture.js";
import { InterfaceBuilderDecodeBudgetExceeded } from "./InterfaceBuilderDecodeBudget.js";
const archiveWithOverlappingObjectRanges = (objectCount: number): Buffer => {
const objectRecords = Buffer.alloc(objectCount * 3);
for (let index = 0; index < objectCount; index += 1)
objectRecords.set([0x80, 0x80, 0x81], index * 3);
const keys = Buffer.from([0x81, 0x78]);
const values = Buffer.from([0x80, 10, 0, 0, 0, 0]);
const className = Buffer.from("NSObject");
const classes = Buffer.concat([
Buffer.from([className.length]),
Buffer.from([0x80]),
className,
]);
const objectsOffset = 50;
const keysOffset = objectsOffset + objectRecords.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(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, objectRecords, keys, values, classes]);
};
describe("NIBArchive decoder", () => {
it("decodes bounded object, key, class, and reference tables", () => {
@ -45,4 +77,19 @@ describe("NIBArchive decoder", () => {
/object reference/u,
);
});
it("budgets overlapping object value ranges before materializing their fields", () => {
const archive = archiveWithOverlappingObjectRanges(700_000);
let thrown: unknown;
try {
decodeNibArchive(archive);
} catch (cause: unknown) {
thrown = cause;
}
expect(thrown).toBeInstanceOf(InterfaceBuilderDecodeBudgetExceeded);
expect(thrown).toHaveProperty(
"message",
expect.stringMatching(/projected field ranges/u),
);
});
});

View File

@ -1,8 +1,12 @@
import type { JsonValue } from "../../domain/jsonValue.js";
import { TextDecoder } from "node:util";
import {
InterfaceBuilderDecodeBudget,
InterfaceBuilderDecodeBudgetExceeded,
} from "./InterfaceBuilderDecodeBudget.js";
const MAGIC = Buffer.from("NIBArchive", "ascii");
const MAX_RECORDS = 1_000_000;
const DEFAULT_DECODE_BUDGET_BYTES = 128 * 1024 * 1024;
interface NibObjectRecord {
readonly class_index: number;
@ -31,7 +35,10 @@ export interface NibArchiveDocument {
}
/** Decode Apple's length-prefixed NIBArchive representation without loading UI classes. */
export const decodeNibArchive = (bytes: Buffer): NibArchiveDocument => {
export const decodeNibArchive = (
bytes: Buffer,
budget = new InterfaceBuilderDecodeBudget(DEFAULT_DECODE_BUDGET_BYTES),
): NibArchiveDocument => {
if (bytes.length < 50 || !bytes.subarray(0, MAGIC.length).equals(MAGIC))
throw new TypeError("NIBArchive magic or header is invalid");
@ -57,6 +64,19 @@ export const decodeNibArchive = (bytes: Buffer): NibArchiveDocument => {
valuesOffset,
classesOffset,
]);
const projectedFieldCount = preflightProjectedFieldCount(
bytes,
objectsOffset,
keysOffset,
objectCount,
valueCount,
classCount,
budget.remainingBytes,
);
budget.reserve(
objectCount * 96 + projectedFieldCount * 96,
"NIBArchive object and projected field counts exceed the aggregate Interface Builder decode budget",
);
const objectRecords = parseObjects(
bytes,
objectsOffset,
@ -65,8 +85,21 @@ export const decodeNibArchive = (bytes: Buffer): NibArchiveDocument => {
valueCount,
classCount,
);
const keys = parseStrings(bytes, keysOffset, valuesOffset, keyCount, "key");
const classes = parseClasses(bytes, classesOffset, bytes.length, classCount);
const keys = parseStrings(
bytes,
keysOffset,
valuesOffset,
keyCount,
"key",
budget,
);
const classes = parseClasses(
bytes,
classesOffset,
bytes.length,
classCount,
budget,
);
const neededValues = valueIndexes(objectRecords, valueCount);
const values = parseValues(
bytes,
@ -75,6 +108,7 @@ export const decodeNibArchive = (bytes: Buffer): NibArchiveDocument => {
valueCount,
keyCount,
neededValues,
budget,
);
for (const { value } of values.values()) {
if (
@ -158,18 +192,64 @@ const parseObjects = (
return output;
};
const preflightProjectedFieldCount = (
bytes: Buffer,
start: number,
end: number,
objectCount: number,
valueCount: number,
classCount: number,
maxBytes: number,
): number => {
if (objectCount * 96 > maxBytes)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"NIBArchive object count exceeds the aggregate Interface Builder decode budget",
);
const cursor = { offset: start };
let projectedFields = 0;
for (let index = 0; index < objectCount; index += 1) {
const classIndex = readVarint(bytes, cursor, end);
const valueStart = readVarint(bytes, cursor, end);
const fieldCount = readVarint(bytes, cursor, end);
if (
classIndex >= classCount ||
valueStart > valueCount ||
fieldCount > valueCount - valueStart
)
throw new TypeError(
`NIBArchive object ${index} references an invalid table range`,
);
projectedFields += fieldCount;
if (
!Number.isSafeInteger(projectedFields) ||
projectedFields * 96 > maxBytes - objectCount * 96
)
throw new InterfaceBuilderDecodeBudgetExceeded(
"aggregate_decode_budget_exhausted",
"NIBArchive projected field ranges exceed the aggregate Interface Builder decode budget",
);
}
return projectedFields;
};
const parseStrings = (
bytes: Buffer,
start: number,
end: number,
count: number,
label: string,
budget: InterfaceBuilderDecodeBudget,
): string[] => {
const cursor = { offset: start };
const output: string[] = [];
for (let index = 0; index < count; index += 1) {
const length = readVarint(bytes, cursor, end);
const next = checkedEnd(cursor.offset, length, end, `${label} ${index}`);
budget.reserve(
32 + length * 2,
`NIBArchive ${label} strings exceed the aggregate Interface Builder decode budget`,
);
const value = decodeUtf8(bytes, cursor.offset, next, `${label} ${index}`);
cursor.offset = next;
output.push(value);
@ -182,6 +262,7 @@ const parseClasses = (
start: number,
end: number,
count: number,
budget: InterfaceBuilderDecodeBudget,
): string[] => {
const cursor = { offset: start };
const output: string[] = [];
@ -200,6 +281,10 @@ const parseClasses = (
);
cursor.offset += extraCount * 4;
const next = checkedEnd(cursor.offset, length, end, `class ${index}`);
budget.reserve(
32 + length * 2,
"NIBArchive class names exceed the aggregate Interface Builder decode budget",
);
const name = decodeUtf8(
bytes,
cursor.offset,
@ -219,6 +304,7 @@ const parseValues = (
count: number,
keyCount: number,
needed: ReadonlySet<number>,
budget: InterfaceBuilderDecodeBudget,
): Map<number, NibValueRecord> => {
const cursor = { offset: start };
const output = new Map<number, NibValueRecord>();
@ -230,8 +316,16 @@ const parseValues = (
);
ensureRange(cursor.offset, 1, end, `value ${index} type`);
const type = bytes[cursor.offset++] ?? -1;
const value = readValue(bytes, cursor, end, type, index);
if (needed.has(index)) output.set(index, { key_index: keyIndex, value });
if (!needed.has(index)) {
skipValue(bytes, cursor, end, type, index);
continue;
}
budget.reserve(
64,
"NIBArchive decoded values exceed the aggregate Interface Builder decode budget",
);
const value = readValue(bytes, cursor, end, type, index, budget);
output.set(index, { key_index: keyIndex, value });
}
if (cursor.offset > end)
throw new TypeError("NIBArchive value table overlaps the next table");
@ -244,6 +338,7 @@ const readValue = (
end: number,
type: number,
index: number,
budget: InterfaceBuilderDecodeBudget,
): JsonValue => {
switch (type) {
case 0:
@ -299,6 +394,10 @@ const readValue = (
end,
`value ${index} data`,
);
budget.reserve(
32 + Math.ceil((length * 4) / 3),
`NIBArchive data value ${index} exceeds the aggregate Interface Builder decode budget`,
);
const encoded = bytes.subarray(cursor.offset, next).toString("base64");
cursor.offset = next;
return { $nib_data_base64: encoded };
@ -319,6 +418,57 @@ const readValue = (
}
};
const skipValue = (
bytes: Buffer,
cursor: { offset: number },
end: number,
type: number,
index: number,
): void => {
switch (type) {
case 0:
ensureRange(cursor.offset, 1, end, `value ${index}`);
cursor.offset += 1;
return;
case 1:
ensureRange(cursor.offset, 2, end, `value ${index}`);
cursor.offset += 2;
return;
case 2:
case 10:
ensureRange(cursor.offset, 4, end, `value ${index}`);
cursor.offset += 4;
return;
case 3:
case 7:
ensureRange(cursor.offset, 8, end, `value ${index}`);
cursor.offset += 8;
return;
case 6:
ensureRange(cursor.offset, 4, end, `value ${index}`);
cursor.offset += 4;
return;
case 4:
case 5:
case 9:
return;
case 8: {
const length = readVarint(bytes, cursor, end);
cursor.offset = checkedEnd(
cursor.offset,
length,
end,
`value ${index} data`,
);
return;
}
default:
throw new TypeError(
`NIBArchive value ${index} has unsupported type ${type}`,
);
}
};
const valueIndexes = (
objects: readonly NibObjectRecord[],
total: number,
@ -340,8 +490,8 @@ const valueIndexes = (
const readCount = (bytes: Buffer, offset: number, label: string): number => {
const count = readU32(bytes, offset);
if (count > MAX_RECORDS)
throw new TypeError(`NIBArchive ${label} count exceeds the decoder bound`);
if (count > bytes.length)
throw new TypeError(`NIBArchive ${label} count exceeds the archive length`);
return count;
};

View File

@ -10,6 +10,8 @@ export type NibHierarchyNode = {
export const projectNibViewHierarchy = (
objects: NibArchiveDocument["objects"],
retainedIds: ReadonlySet<number>,
reserveNode: () => boolean = () => true,
maxNodes = 20_000,
) => {
const { parents, involved, invalid } = collectNibParents(
objects,
@ -32,7 +34,12 @@ export const projectNibViewHierarchy = (
while (pending.length > 0) {
const item = pending.pop();
if (item === undefined) break;
if (visited.has(item.id) || item.depth > 128 || visited.size >= 20_000)
if (
visited.has(item.id) ||
item.depth > 128 ||
visited.size >= maxNodes ||
!reserveNode()
)
continue;
visited.add(item.id);
const node: NibHierarchyNode = { objectID: String(item.id), children: [] };
@ -50,6 +57,8 @@ export const projectNibViewHierarchy = (
export const mergeNibHierarchies = (
legacy: NibHierarchyNode[],
recovered: NibHierarchyNode[],
reserveNode: () => boolean = () => true,
maxNodes = 20_000,
) => {
const existing = new Map<string, NibHierarchyNode>();
const owners = new Map<string, NibHierarchyNode[]>();
@ -84,6 +93,10 @@ export const mergeNibHierarchies = (
if (entry === undefined) break;
let target = existing.get(entry.node.objectID);
if (target === undefined) {
if (existing.size >= maxNodes || !reserveNode()) {
omitted += 1;
continue;
}
target = { objectID: entry.node.objectID, children: [] };
existing.set(target.objectID, target);
entry.output.push(target);

View File

@ -75,6 +75,27 @@ const keyedArchiveHierarchy = (
});
};
const binaryPlistWithRepeatedReferences = (depth: number): Buffer => {
const encodedObjects = [Buffer.from([0x51, 0x78])];
for (let index = 1; index <= depth; index += 1)
encodedObjects.push(Buffer.from([0xa2, index - 1, index - 1]));
const header = Buffer.from("bplist00", "ascii");
const offsets: number[] = [];
let offset = header.length;
for (const object of encodedObjects) {
offsets.push(offset);
offset += object.length;
}
const offsetTable = Buffer.from(offsets);
const trailer = Buffer.alloc(32);
trailer[6] = 1;
trailer[7] = 1;
trailer.writeBigUInt64BE(BigInt(encodedObjects.length), 8);
trailer.writeBigUInt64BE(BigInt(depth), 16);
trailer.writeBigUInt64BE(BigInt(offset), 24);
return Buffer.concat([header, ...encodedObjects, offsetTable, trailer]);
};
describe("compiled Interface Builder bundle reader", () => {
it("reads nib plist archives and reports provenance", async () => {
const root = await createTestTempDirectory("rea-ib-test-");
@ -381,6 +402,173 @@ describe("flat Interface Builder plist values", () => {
});
});
describe("bounded Interface Builder archive decoding", () => {
it("marks repeated NIB hierarchy references partial at the caller object limit", async () => {
const root = await createTestTempDirectory("rea-ib-repeated-hierarchy-");
const bundle = join(root, "Example.app");
await mkdir(bundle, { recursive: true });
const depth = 8;
const objects = [
{ classIndex: 0, values: { NSRoot: { ref: 1 } } },
...Array.from({ length: depth + 1 }, (_, index) => {
const viewId = 1 + index * 2;
return [
{
classIndex: 1,
values: index === depth ? {} : { subviews: { ref: viewId + 1 } },
},
...(index === depth
? []
: [
{
classIndex: 2,
values: {
first: { ref: viewId + 2 },
second: { ref: viewId + 2 },
},
},
]),
];
}).flat(),
];
await writeFile(
join(bundle, "RepeatedHierarchy.nib"),
encodeNibArchiveFixture({
classes: ["NSIBObjectData", "UIView", "NSArray"],
objects,
}),
);
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "f".repeat(64),
limits: { max_objects: 16 },
});
expect(result.documents[0]?.hierarchy_complete).toBe(false);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "hierarchy:RepeatedHierarchy.nib",
status: "partial",
reason: "serialized_view_hierarchy_incomplete",
}),
);
});
it("rejects a binary plist expansion before recursively materializing repeated references", async () => {
const root = await createTestTempDirectory("rea-ib-expansion-test-");
const bundle = join(root, "Example.app");
await mkdir(bundle, { recursive: true });
await writeFile(
join(bundle, "RepeatedReferences.nib"),
binaryPlistWithRepeatedReferences(32),
);
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "f".repeat(64),
});
expect(result.documents).toEqual([]);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "partial",
reason: "aggregate_decode_budget_exhausted",
examined: 1,
omitted: 1,
}),
);
expect(result.limitations).toContain(
"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-");
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 } },
],
}),
);
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "f".repeat(64),
});
expect(result.documents).toEqual([]);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "partial",
reason: "aggregate_decode_budget_exhausted",
examined: 1,
omitted: 1,
}),
);
expect(result.limitations).toContain(
"RepeatedString.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-");
const bundle = join(root, "Example.app");
const resources = join(bundle, "Contents", "Resources");
await mkdir(resources, { recursive: true });
const padding = `<!--${"x".repeat(1024)}-->`.repeat(11 * 1024);
const valid = (name: string, body = "") =>
`<?xml version="1.0"?><plist version="1.0"><dict><key>document</key><string>${name}</string>${body}</dict></plist>`;
// XML data expands to a Uint8Array and then to a base64 JSON projection;
// this first valid document exercises expansion beyond the source bytes.
const expandedData = Buffer.alloc(1024 * 1024, 7).toString("base64");
await writeFile(
join(resources, "A.nib"),
valid("A", `<key>payload</key><data>${expandedData}</data>`),
);
await writeFile(join(resources, "B.nib"), valid("B", padding));
await writeFile(join(resources, "C.nib"), valid("C", padding));
await writeFile(join(resources, "D.nib"), valid("D", padding));
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "e".repeat(64),
});
expect(result.documents.map(({ relative_path }) => relative_path)).toEqual([
"Contents/Resources/A.nib",
"Contents/Resources/B.nib",
"Contents/Resources/C.nib",
]);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "partial",
reason: "aggregate_decode_budget_exhausted",
examined: 3,
omitted: 1,
}),
);
expect(result.limitations).toContain(
"Contents/Resources/D.nib: omitted because aggregate decode budget exhausted.",
);
});
});
describe("bounded Interface Builder archive decoding", () => {
it("counts malformed archives against the document limit", async () => {
const root = await createTestTempDirectory("rea-ib-test-");