fix(apple): budget Interface Builder decoding and projection
This commit is contained in:
parent
f2e9c491ea
commit
dbcf8c3391
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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-");
|
||||
|
|
|
|||
Loading…
Reference in New Issue