fix(plist): disclose binary prototype-key omissions

Reuse the bounded binary preflight to observe dictionary keys before the pinned decoder drops them. Keyed archive results disclose the omission and Interface Builder marks archive decoding partial. XML keeps its existing exact counts; binary reporting deliberately leaves the count unknown because overwritten subtrees and decoder projection can change visibility.

Remove the superseded size-only preflight contract. Cover ASCII, UTF-16 and extended lengths, shared references, nested and shadowed dictionaries, and the value-only negative control. Synthetic plist and filesystem fixtures; no plutil or ibtool verification.

Fixes #1594. Supersedes #1593.
This commit is contained in:
morluto
2026-10-11 07:02:39 +08:00
parent 975dff2e25
commit 8d3cfef753
6 changed files with 281 additions and 43 deletions
+23 -13
View File
@@ -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<string, number>();
const prototypeKeyOmissions = new Map<string, number>();
const prototypeKeyOmissions = new Map<string, number | null>();
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<typeof projectNibArchive> | 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<typeof buildInterfaceBuilderAnalysis>,
counts: {
incompleteHierarchies: ReadonlyMap<string, number>;
prototypeKeyOmissions: ReadonlyMap<string, number>;
prototypeKeyOmissions: ReadonlyMap<string, number | null>;
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 };
};
@@ -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<number>();
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 };
};
@@ -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.",
);
});
});
+21 -15
View File
@@ -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,
);
+4 -2
View File
@@ -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,
@@ -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-");