diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index e15a81b44..0f9506a66 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -9,7 +9,7 @@ import { } from "../../domain/propertyListKeys.js"; import { decodeNibArchive, type NibArchiveDocument } from "./NibArchive.js"; import { - estimatePropertyListDecodeBytes, + preflightPropertyListDecode, InterfaceBuilderDecodeBudget, InterfaceBuilderDecodeBudgetExceeded, } from "./InterfaceBuilderDecodeBudget.js"; @@ -47,7 +47,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { const omittedArchives: Array<{ path: string; reason: string }> = []; let unreportedOmittedArchives = 0; const incompleteHierarchies = new Map(); - const prototypeKeyOmissions = new Map(); + const prototypeKeyOmissions = new Map(); let omitted = 0; let attempted = 0; let aggregateInputBytes = 0; @@ -93,6 +93,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { bytes.subarray(0, 10).toString("ascii") === "NIBArchive"; let nib: ReturnType | null = null; let xmlText: string | undefined; + let binaryPrototypeKeyObserved = false; if (isNibArchive) { const documentBudget = new InterfaceBuilderDecodeBudget( decodeBudget.remainingBytes, @@ -109,23 +110,25 @@ export const analyzeInterfaceBuilderBundle = async (input: { } else { if (bytes.subarray(0, 8).toString("ascii") !== "bplist00") xmlText = decodeXmlPlistText(bytes); - const decodedReservation = estimatePropertyListDecodeBytes( + const decodedReservation = preflightPropertyListDecode( bytes, decodeBudget.remainingBytes, xmlText, ); decodeBudget.reserve( - decodedReservation, + decodedReservation.estimatedBytes, "plist representation exceeds the aggregate Interface Builder decode budget", ); + binaryPrototypeKeyObserved = + decodedReservation.binaryPrototypeKeyObserved; } const { value: raw, omittedPrototypeKeys } = nib === null - ? decodePlist(bytes, xmlText) + ? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved) : { value: nib.raw, omittedPrototypeKeys: 0 }; if (nib !== null && nib.omitted > 0) incompleteHierarchies.set(entry.path, nib.omitted); - if (omittedPrototypeKeys > 0) + if (omittedPrototypeKeys !== 0) prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys); const documentHash = createHash("sha256").update(bytes).digest("hex"); documents.push({ @@ -226,7 +229,7 @@ const finalizeHierarchyCoverage = ( result: ReturnType, counts: { incompleteHierarchies: ReadonlyMap; - prototypeKeyOmissions: ReadonlyMap; + prototypeKeyOmissions: ReadonlyMap; invalid: readonly string[]; omittedArchives: readonly { path: string; reason: string }[]; unreportedOmittedArchives: number; @@ -641,11 +644,18 @@ 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(xmlText ?? decodeXmlPlistText(bytes)); + xmlText: string | undefined, + binaryPrototypeKeyObserved: boolean, +): { + readonly value: JsonValue; + readonly omittedPrototypeKeys: number | null; +} => { + const binary = bytes.subarray(0, 8).toString("ascii") === "bplist00"; + const { value, omittedPrototypeKeys } = binary + ? { + value: parseBinary(bytes), + omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0, + } + : parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes)); return { value: projectPlistValue(value).value, omittedPrototypeKeys }; }; diff --git a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts index a43529634..4dc3d6c64 100644 --- a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts +++ b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts @@ -89,21 +89,24 @@ export class InterfaceBuilderDecodeBudget { } /** Preflight the representation created by the pinned plist decoder. */ -export const estimatePropertyListDecodeBytes = ( +export const preflightPropertyListDecode = ( bytes: Buffer, maxBytes: number, xmlText?: string, -): number => { - const estimate = +): PlistDecodePreflight => { + const expansion = bytes.subarray(0, 8).toString("ascii") === "bplist00" ? estimateBinaryPlistExpansion(bytes, maxBytes) - : estimateXmlPlistExpansion(bytes, maxBytes, xmlText); - if (estimate > maxBytes) + : { + estimatedBytes: estimateXmlPlistExpansion(bytes, maxBytes, xmlText), + binaryPrototypeKeyObserved: false, + }; + if (expansion.estimatedBytes > maxBytes) throw new InterfaceBuilderDecodeBudgetExceeded( "aggregate_decode_budget_exhausted", "plist representation exceeds the aggregate Interface Builder decode budget", ); - return estimate; + return expansion; }; const estimateXmlPlistExpansion = ( @@ -173,6 +176,14 @@ export interface BinaryPlistExpansionOptions { readonly bounds?: "strict" | "decoder"; } +/** Preflight size and binary-key observations; XML keys are inspected by decode. */ +export interface PlistDecodePreflight { + readonly estimatedBytes: number; + readonly binaryPrototypeKeyObserved: boolean; +} + +const PROTOTYPE_KEY = "__proto__"; + /** * Count every expanded binary-plist reference before calling plist.parseBinary, * which recurses on each reference and copies shared containers. Malformed @@ -186,7 +197,7 @@ export const estimateBinaryPlistExpansion = ( fail = interfaceBuilderBudgetFailure, bounds = "strict", }: BinaryPlistExpansionOptions = {}, -): number => { +): PlistDecodePreflight => { if (bytes.length < 40) throw new TypeError("binary plist trailer is truncated"); const trailer = bytes.length - 32; @@ -218,13 +229,17 @@ export const estimateBinaryPlistExpansion = ( ) throw new TypeError("binary plist trailer or object table is invalid"); let estimate = bytes.length * 2 + objectCount * 16; + let binaryPrototypeKeyObserved = false; if (estimate > maxBytes) throw fail("expansion", `binary plist object table exceeds ${budget}`); const active = new Set(); - const pending: Array<{ object: number; depth: number; exit: boolean }> = [ - { object: topObject, depth: 0, exit: false }, - ]; + const pending: Array<{ + object: number; + depth: number; + exit: boolean; + dictionaryKey: boolean; + }> = [{ object: topObject, depth: 0, exit: false, dictionaryKey: false }]; let pendingObjectCount = 1; while (pending.length > 0) { const entry = pending.pop(); @@ -269,6 +284,26 @@ export const estimateBinaryPlistExpansion = ( const byteLength = type === 6 ? size * 2 : size; if (cursor + byteLength > objectsEnd) throw new TypeError("binary plist scalar object is truncated"); + if ( + !binaryPrototypeKeyObserved && + entry.dictionaryKey && + (type === 5 || type === 6) && + size === PROTOTYPE_KEY.length + ) { + let matches = true; + for (let index = 0; index < PROTOTYPE_KEY.length; index += 1) { + const codeUnit = + type === 5 + ? (bytes[cursor + index] ?? 0) + : ((bytes[cursor + index * 2] ?? 0) << 8) | + (bytes[cursor + index * 2 + 1] ?? 0); + if (codeUnit !== PROTOTYPE_KEY.charCodeAt(index)) { + matches = false; + break; + } + } + if (matches) binaryPrototypeKeyObserved = true; + } estimate += type === 4 ? size + 4 * Math.ceil(size / 3) : byteLength * 2; if (estimate > maxBytes) throw fail( @@ -289,7 +324,12 @@ export const estimateBinaryPlistExpansion = ( `binary plist reference expansion exceeds ${budget}`, ); active.add(entry.object); - pending.push({ object: entry.object, depth: entry.depth, exit: true }); + pending.push({ + object: entry.object, + depth: entry.depth, + exit: true, + dictionaryKey: false, + }); for (let index = childCount - 1; index >= 0; index -= 1) { const reference = readInteger( cursor + index * referenceSize, @@ -297,9 +337,14 @@ export const estimateBinaryPlistExpansion = ( ); if (reference < 0 || reference >= objectCount) throw new TypeError("binary plist reference is invalid"); - pending.push({ object: reference, depth: entry.depth + 1, exit: false }); + pending.push({ + object: reference, + depth: entry.depth + 1, + exit: false, + dictionaryKey: type === 13 && index < size, + }); } pendingObjectCount += childCount; } - return estimate; + return { estimatedBytes: estimate, binaryPrototypeKeyObserved }; }; diff --git a/src/artifacts/apple/KeyedArchiveReader.test.ts b/src/artifacts/apple/KeyedArchiveReader.test.ts index c6a1319f1..2268858b5 100644 --- a/src/artifacts/apple/KeyedArchiveReader.test.ts +++ b/src/artifacts/apple/KeyedArchiveReader.test.ts @@ -65,6 +65,40 @@ describe("inert keyed archive decoding", () => { "1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.", ); }); + it("reports a binary archive dictionary keyed __proto__ as omitted", () => { + const placeholder = "proto_key"; + const encoded = Buffer.from( + buildBinary({ + ...archive, + [placeholder]: "hidden", + $objects: ["$null", "value"], + }), + ); + const start = encoded.indexOf(placeholder); + expect(start).toBeGreaterThanOrEqual(0); + expect(encoded.indexOf(placeholder, start + placeholder.length)).toBe(-1); + Buffer.from("__proto__").copy(encoded, start); + + const graph = decodeKeyedArchiveBytes(encoded, { offset: 0, limit: 2 }); + expect(graph.limitations).toContain( + "Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.", + ); + }); + it("does not report a binary __proto__ value as an omitted key", () => { + const graph = decodeKeyedArchiveBytes( + Buffer.from( + buildBinary({ + ...archive, + note: "__proto__", + $objects: ["$null", "value"], + }), + ), + { offset: 0, limit: 2 }, + ); + expect(graph.limitations).not.toContain( + "Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.", + ); + }); it("rejects missing roots, malformed plist, and non-keyed archives", () => { expect(() => decodeKeyedArchiveBytes(Buffer.from("bplist00bad"), { @@ -133,6 +167,20 @@ const plistArray = (references: readonly number[]): Buffer => { }; const plistString = (value: string): Buffer => Buffer.concat([Buffer.from([0x50 | value.length]), Buffer.from(value)]); +const plistUtf16String = (value: string): Buffer => { + const bytes = Buffer.alloc(1 + value.length * 2); + bytes[0] = 0x60 | value.length; + for (let index = 0; index < value.length; index += 1) + bytes.writeUInt16BE(value.charCodeAt(index), 1 + index * 2); + return bytes; +}; +const plistExtendedUtf16String = (value: string): Buffer => { + const bytes = Buffer.alloc(3 + value.length * 2); + bytes.set([0x6f, 0x10, value.length]); + for (let index = 0; index < value.length; index += 1) + bytes.writeUInt16BE(value.charCodeAt(index), 3 + index * 2); + return bytes; +}; // {"$archiver": "NSKeyedArchiver", "$top": {"root": UID 1}, "$objects": object 8} const keyedRoot = (payload: readonly Buffer[]): Buffer => binaryPlist([ @@ -187,4 +235,91 @@ describe("binary plist reference preflight", () => { [2, "$null"], ]); }); + + it("recognizes a shared key object used by separate dictionaries", () => { + const graph = decodeKeyedArchiveBytes( + keyedRoot([ + plistArray([9, 12]), + Buffer.from([0xd1, 0, 10, 0, 11]), + plistString("__proto__"), + plistString("first"), + Buffer.from([0xd1, 0, 10, 0, 13]), + plistString("second"), + ]), + selection, + ); + expect(graph.limitations).toContain( + "Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.", + ); + }); + + it("reports binary omission presence without claiming a count", () => { + const placeholder = "proto_key"; + const nested = Buffer.from( + buildBinary({ + ...archive, + [placeholder]: { [placeholder]: "hidden", visible: true }, + $objects: ["$null", "value"], + }), + ); + let index = nested.indexOf(placeholder); + while (index !== -1) { + Buffer.from("__proto__").copy(nested, index); + index = nested.indexOf(placeholder, index + placeholder.length); + } + const nestedGraph = decodeKeyedArchiveBytes(nested, selection); + expect(nestedGraph.limitations).toContain( + "Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.", + ); + + const shadowedValue = keyedRoot([ + plistArray([9]), + Buffer.from([0xd2, 0, 10, 0, 10, 0, 11, 0, 12]), + plistString("shadow"), + Buffer.from([0xd1, 0, 13, 0, 14]), + plistString("replacement"), + plistString(placeholder), + plistString("hidden"), + ]); + const keyOffset = shadowedValue.indexOf(Buffer.from(placeholder)); + expect(keyOffset).toBeGreaterThanOrEqual(0); + Buffer.from("__proto__").copy(shadowedValue, keyOffset); + const shadowedGraph = decodeKeyedArchiveBytes(shadowedValue, selection); + expect(shadowedGraph.limitations).toContain( + "Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.", + ); + expect(shadowedGraph.limitations).not.toContain( + "1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.", + ); + }); + + it("detects UTF-16 binary dictionary keys", () => { + const graph = decodeKeyedArchiveBytes( + keyedRoot([ + plistArray([9]), + Buffer.from([0xd1, 0, 10, 0, 11]), + plistUtf16String("__proto__"), + plistString("hidden"), + ]), + selection, + ); + expect(graph.limitations).toContain( + "Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.", + ); + }); + + it("detects extended-length UTF-16 binary dictionary keys", () => { + const graph = decodeKeyedArchiveBytes( + keyedRoot([ + plistArray([9]), + Buffer.from([0xd1, 0, 10, 0, 11]), + plistExtendedUtf16String("__proto__"), + plistString("hidden"), + ]), + selection, + ); + expect(graph.limitations).toContain( + "Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.", + ); + }); }); diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 3a24f0962..5d2f8fab5 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -6,7 +6,6 @@ import { AnalysisInputError, AnalysisUnsupportedTargetError, } from "../../domain/analysisErrorCore.js"; -import type { JsonValue } from "../../domain/jsonValue.js"; import { projectPlistValue } from "../../domain/apple/plistValue.js"; import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js"; import { @@ -51,20 +50,27 @@ export const decodeKeyedArchiveBytes = ( // plist.parseBinary recurses on every object reference and copies shared // containers, so a few hundred bytes can exhaust memory or the stack. // RangeError reports a resource limit; a reference cycle is malformed. - if (binary) - estimateBinaryPlistExpansion(bytes, MAX_DECODE_BYTES, { - budget: "the keyed archive decode budget", - fail: (kind, message) => - kind === "cycle" - ? new TypeError( - "binary plist object references form a cycle, which a property list cannot represent", - ) - : new RangeError(message), - bounds: "decoder", - }); - const parsed = binary - ? { value: parseBinary(bytes), omittedPrototypeKeys: 0 } - : parseXmlPropertyList(xmlText ?? ""); + const binaryExpansion = binary + ? estimateBinaryPlistExpansion(bytes, MAX_DECODE_BYTES, { + budget: "the keyed archive decode budget", + fail: (kind, message) => + kind === "cycle" + ? new TypeError( + "binary plist object references form a cycle, which a property list cannot represent", + ) + : new RangeError(message), + bounds: "decoder", + }) + : undefined; + const parsed = + binaryExpansion !== undefined + ? { + value: parseBinary(bytes), + omittedPrototypeKeys: binaryExpansion.binaryPrototypeKeyObserved + ? null + : 0, + } + : parseXmlPropertyList(xmlText ?? ""); const { value: plistValue, unknownRealCount } = projectPlistValue( parsed.value, ); diff --git a/src/domain/propertyListKeys.ts b/src/domain/propertyListKeys.ts index 546abeb16..2c537f38d 100644 --- a/src/domain/propertyListKeys.ts +++ b/src/domain/propertyListKeys.ts @@ -10,8 +10,10 @@ export interface PropertyListWithoutPrototypeKeys { } /** Limitation for results whose source plist held `__proto__` entries. */ -export const omittedPrototypeKeysLimitation = (count: number): string => - `${String(count)} dictionary ${count === 1 ? "entry" : "entries"} keyed __proto__ ${count === 1 ? "was" : "were"} omitted because REA results cannot represent that key.`; +export const omittedPrototypeKeysLimitation = (count: number | null): string => + count === null + ? "Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key." + : `${String(count)} dictionary ${count === 1 ? "entry" : "entries"} keyed __proto__ ${count === 1 ? "was" : "were"} omitted because REA results cannot represent that key.`; const withoutKey = ( value: unknown, diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index b5fabfcb0..788127dc3 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -774,6 +774,46 @@ cliTest( }, ); +describe("binary Interface Builder archive omissions", () => { + it("reports omitted __proto__ entries as partial decoding", async () => { + const placeholder = "proto_key"; + const encoded = Buffer.from( + buildBinary({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null"], + $top: {}, + [placeholder]: "hidden", + }), + ); + const start = encoded.indexOf(placeholder); + expect(start).toBeGreaterThanOrEqual(0); + expect(encoded.indexOf(placeholder, start + placeholder.length)).toBe(-1); + Buffer.from("__proto__").copy(encoded, start); + + const root = await createTestTempDirectory("rea-ib-test-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + await writeFile(join(resources, "Prototype.nib"), encoded); + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "f".repeat(64), + }); + + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "archive_decode", + status: "partial", + reason: "dictionary_entries_omitted", + }), + ); + expect(result.limitations).toContain( + "Contents/Resources/Prototype.nib: Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.", + ); + }); +}); + 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-");