fix(plist): own dictionary decoding across Apple workflows

Preserve XML, OpenStep and binary dictionary keys at their format owners. Share validated binary reference facts with materialization while retaining scalar precision, UID identity and bounded expansion. Migrate archive, Interface Builder, native and app-bundle consumers, then remove obsolete key-omission accounting and schema copies.

Validated source regressions and compiled CLI/MCP workflows, including native plist fidelity and archive identity. Typecheck, lint, docs and test discovery pass. No performance benchmarks were required.
This commit is contained in:
morluto committed 2026-10-26 22:20:00 +08:00
1 parent 3b26d7f80b
commit eb06e263cb
35 files changed
+1006 -785

No files matched your search

@@ -0,0 +1,82 @@
import { expect, it } from "vitest";
import { binaryPlistObjectTable } from "../../../tests/fixtures/binaryPlistObjectTable.js";
import { plistUidValue } from "../../domain/apple/plistUid.js";
import { decodeBinaryPlistValue } from "./BinaryPlistMaterializer.js";
import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js";
const source = (objects: readonly Buffer[]): Buffer => {
const table =
8 + objects.reduce((length, object) => length + object.length, 0);
const bytes = Buffer.alloc(table + objects.length + 32);
let cursor = 8;
const offsets = objects.map((object) => {
const offset = cursor;
object.copy(bytes, cursor);
cursor += object.length;
return offset;
});
return binaryPlistObjectTable(bytes, offsets, table);
};
const text = (value: string): Buffer =>
Buffer.concat([Buffer.from([0x50 | value.length]), Buffer.from(value)]);
const decode = (bytes: Buffer) =>
decodeBinaryPlistValue(
bytes,
estimateBinaryPlistExpansion(bytes, 1024 * 1024, {
uidProjection: true,
numberProjection: true,
}),
);
it("materializes own dictionary keys and keeps the last duplicate's source UID", () => {
const bytes = source([
Buffer.from([0xd3, 1, 2, 1, 3, 4, 5]),
text("__proto__"),
text("constructor"),
Buffer.from([0x80, 1]),
text("kept"),
Buffer.from([0x80, 2]),
]);
const value = decode(bytes) as Record<string, unknown>;
expect(Object.keys(value)).toEqual(["__proto__", "constructor"]);
expect(Object.getPrototypeOf(value)).toBe(Object.prototype);
expect(plistUidValue(value["__proto__"])).toBe(2);
expect(value.constructor).toBe("kept");
});
it("expands shared containers and nonprimitive scalar references independently", () => {
const date = Buffer.alloc(9);
date[0] = 0x33;
date.writeDoubleBE(1, 1);
const bytes = source([
Buffer.from([0xa8, 1, 1, 2, 2, 3, 3, 4, 4]),
Buffer.from([0xd1, 5, 6]),
date,
Buffer.from([0x42, 1, 2]),
Buffer.from([0x80, 7]),
text("nested"),
text("kept"),
]);
const value = decode(bytes) as unknown[];
for (let index = 0; index < value.length; index += 2) {
expect(value[index]).toEqual(value[index + 1]);
expect(value[index]).not.toBe(value[index + 1]);
}
expect(plistUidValue(value[6])).toBe(7);
expect(plistUidValue(value[7])).toBe(7);
});
it("keeps a source UID distinct from an ordinary UID-shaped dictionary", () => {
const bytes = source([
Buffer.from([0xa2, 1, 2]),
Buffer.from([0x80, 7]),
Buffer.from([0xd1, 3, 4]),
text("UID"),
Buffer.from([0x10, 7]),
]);
const value = decode(bytes) as unknown[];
expect(value[0]).toEqual({ UID: 7 });
expect(value[1]).toEqual({ UID: 7 });
expect(plistUidValue(value[0])).toBe(7);
expect(plistUidValue(value[1])).toBeUndefined();
});
@@ -0,0 +1,125 @@
import { parseBinary } from "plist";
import { markPlistDictionary } from "../../domain/apple/plistUid.js";
import {
binaryPlistObjectOffsets,
decodeBinaryPlistScalars,
scanBinaryPlistScalars,
type BinaryPlistScalarMetadata,
} from "./PlistScalars.js";
import type { PlistDecodePreflight } from "./PropertyListDecodeBudget.js";
/** Build one array of source scalar references; upstream never constructs a dictionary. */
const scalarArrayInput = (
bytes: Buffer,
objects: readonly number[],
): Buffer => {
const trailer = bytes.length - 32;
const referenceWidth = bytes[trailer + 7]!;
const objectCount = Number(bytes.readBigUInt64BE(trailer + 8));
const rootOffset = bytes.length;
const rootLength = 10 + objects.length * referenceWidth;
const tableOffset = rootOffset + rootLength;
const output = Buffer.alloc(tableOffset + (objectCount + 1) * 8 + 32);
bytes.copy(output);
output[rootOffset] = 0xaf;
output[rootOffset + 1] = 0x13;
output.writeBigUInt64BE(BigInt(objects.length), rootOffset + 2);
for (const [index, object] of objects.entries()) {
let reference = BigInt(object);
const offset = rootOffset + 10 + index * referenceWidth;
for (let byte = referenceWidth - 1; byte >= 0; byte -= 1) {
output[offset + byte] = Number(reference & 0xffn);
reference >>= 8n;
}
}
let object = 0;
for (const offset of binaryPlistObjectOffsets(bytes)) {
output.writeBigUInt64BE(offset, tableOffset + object * 8);
object += 1;
}
output.writeBigUInt64BE(BigInt(rootOffset), tableOffset + objectCount * 8);
const nextTrailer = output.length - 32;
output[nextTrailer + 6] = 8;
output[nextTrailer + 7] = referenceWidth;
output.writeBigUInt64BE(BigInt(objectCount + 1), nextTrailer + 8);
output.writeBigUInt64BE(BigInt(objectCount), nextTrailer + 16);
output.writeBigUInt64BE(BigInt(tableOffset), nextTrailer + 24);
return output;
};
/** Preserve each source reference's scalar identity while retaining upstream scalar semantics. */
const scalarOccurrence = (
value: unknown,
uid: boolean,
identity: boolean,
): unknown => {
if (value instanceof Date) return new Date(value.getTime());
if (value instanceof Uint8Array)
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
if (uid && value !== null && typeof value === "object") {
const copy = { ...value };
if (identity) markPlistDictionary(copy, (value as { UID: number }).UID);
return copy;
}
return value;
};
/** Decode validated source containers without losing dictionary keys or alias occurrence identity. */
export const decodeBinaryPlistValue = (
bytes: Buffer,
plan: PlistDecodePreflight,
metadata: BinaryPlistScalarMetadata = scanBinaryPlistScalars(bytes, plan),
): unknown => {
const source = plan.binaryValuePlan;
if (source === undefined)
throw new TypeError("Binary plist source plan is missing");
const compatible = decodeBinaryPlistScalars(bytes, metadata, plan);
const scalars = parseBinary(
scalarArrayInput(
compatible,
source.scalars.map(({ object }) => object),
),
) as unknown[];
const scalarIndex = new Map(
source.scalars.map((scalar, index) => [scalar.object, index]),
);
const containers = new Map(
source.containers.map((container) => [container.object, container]),
);
const root: { value?: unknown } = {};
const pending: { object: number; parent: object; key: string | number }[] = [
{ object: source.topObject, parent: root, key: "value" },
];
while (pending.length > 0) {
const entry = pending.pop()!;
const container = containers.get(entry.object);
let value: unknown;
if (container === undefined) {
const index = scalarIndex.get(entry.object);
if (index === undefined)
throw new TypeError("Binary plist scalar source is missing");
value = scalarOccurrence(
scalars[index],
source.scalars[index]!.uid,
source.uidIdentity,
);
} else {
value = container.kind === "array" ? [] : {};
if (container.kind === "dictionary" && source.uidIdentity)
markPlistDictionary(value as object);
for (let index = container.references.length - 1; index >= 0; index -= 1)
pending.push({
object: container.references[index]!,
parent: value as object,
key: container.keys?.[index] ?? index,
});
}
Object.defineProperty(entry.parent, entry.key, {
value,
enumerable: true,
configurable: true,
writable: true,
});
}
return root.value;
};
+18 -53
View File
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { TextDecoder } from "node:util";
import { parseBinary } from "plist";
import { decodeBinaryPlistValue } from "./BinaryPlistMaterializer.js";
import { appleResourceKeyFields } from "../../domain/apple/appleAssetCatalog.js";
import {
@@ -23,16 +24,16 @@ import {
unknownDateLimitation,
unknownScalarLimitation,
} from "../../domain/apple/plistValue.js";
import {
omittedPrototypeKeysLimitation,
parseXmlPropertyList,
} from "../../domain/propertyListKeys.js";
import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
import {
decodeNibArchive,
nibCollectionMembers,
type NibArchiveDocument,
} from "./NibArchive.js";
import { preflightPropertyListDecode } from "./PropertyListDecodeBudget.js";
import {
preflightPropertyListDecode,
type PlistDecodePreflight,
} from "./PropertyListDecodeBudget.js";
import {
interfaceBuilderDecodeBudgetFailure,
InterfaceBuilderDecodeBudget,
@@ -58,11 +59,8 @@ import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"
import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
import {
decodeBinaryPlistScalars,
retainBinaryPlistUidIdentity,
scanBinaryPlistScalars,
UnsupportedBinaryPlistUidError,
type BinaryPlistScalarDecodePlan,
} from "./PlistScalars.js";
import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js";
import {
@@ -111,7 +109,6 @@ const decodeInterfaceBuilderBundle = async (
const incompleteHierarchies = new Map<string, number>();
const unexaminedSources = new Map<string, readonly NibUnexaminedSource[]>();
const unresolvedConnections = new Map<string, number>();
const prototypeKeyOmissions = new Map<string, number | null>();
const unknownDates = new Map<string, number>();
const datePrecisionOverlaps = new Map<string, number>();
const unknownScalars = new Map<string, number>();
@@ -178,15 +175,7 @@ const decodeInterfaceBuilderBundle = async (
const isNibArchive = hasNibArchiveSignature(bytes);
let nib: ReturnType<typeof projectNibArchive> | null = null;
let xmlText: string | undefined;
let binaryPrototypeKeyObserved = false;
let scalarPlan: BinaryPlistScalarDecodePlan = {
reachableDateOffsets: [],
overlappingDateOffsets: [],
reachableWideIntegerOffsets: [],
overlappingWideIntegerOffsets: [],
reachableNumericOffsets: [],
uidOffsetsToNormalize: [],
};
let scalarPlan!: PlistDecodePreflight;
let datePrecisionOverlapCount = 0;
if (isNibArchive) {
const documentBudget = new InterfaceBuilderDecodeBudget(
@@ -220,23 +209,19 @@ const decodeInterfaceBuilderBundle = async (
"plist representation exceeds the aggregate Interface Builder decode budget",
);
plistReservation = decodedReservation.estimatedBytes;
binaryPrototypeKeyObserved =
decodedReservation.binaryPrototypeKeyObserved;
scalarPlan = decodedReservation;
datePrecisionOverlapCount =
decodedReservation.datePrecisionOverlapCount;
}
const {
value: raw,
omittedPrototypeKeys,
unknownDateCount,
unknownScalarCount,
numberLiterals,
} = nib === null
? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved, scalarPlan)
? decodePlist(bytes, xmlText, scalarPlan)
: {
value: nib.raw,
omittedPrototypeKeys: 0,
unknownDateCount: 0,
unknownScalarCount: 0,
numberLiterals: undefined,
@@ -252,8 +237,6 @@ const decodeInterfaceBuilderBundle = async (
nonFiniteReals.set(entry.path, nib.nonFiniteRealCount);
if (nib !== null && nib.negativeZeroRealCount > 0)
negativeZeroReals.set(entry.path, nib.negativeZeroRealCount);
if (omittedPrototypeKeys !== 0)
prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys);
if (unknownDateCount !== 0)
unknownDates.set(entry.path, unknownDateCount);
if (datePrecisionOverlapCount > 0)
@@ -321,7 +304,6 @@ const decodeInterfaceBuilderBundle = async (
unexaminedSources,
incompleteHierarchies,
unresolvedConnections,
prototypeKeyOmissions,
unknownDates,
datePrecisionOverlaps,
unknownScalars,
@@ -423,7 +405,6 @@ const finalizeHierarchyCoverage = (
unexaminedSources: ReadonlyMap<string, readonly NibUnexaminedSource[]>;
incompleteHierarchies: ReadonlyMap<string, number>;
unresolvedConnections: ReadonlyMap<string, number>;
prototypeKeyOmissions: ReadonlyMap<string, number | null>;
unknownDates: ReadonlyMap<string, number>;
datePrecisionOverlaps: ReadonlyMap<string, number>;
unknownScalars: ReadonlyMap<string, number>;
@@ -441,7 +422,6 @@ const finalizeHierarchyCoverage = (
unexaminedSources,
incompleteHierarchies,
unresolvedConnections,
prototypeKeyOmissions,
unknownDates,
datePrecisionOverlaps,
unknownScalars,
@@ -454,7 +434,6 @@ const finalizeHierarchyCoverage = (
const archiveDecodePartial =
invalid.length > 0 ||
unexaminedSources.size > 0 ||
prototypeKeyOmissions.size > 0 ||
counts.omitted > 0 ||
counts.traversal.status === "unknown";
return {
@@ -521,13 +500,11 @@ const finalizeHierarchyCoverage = (
? "archive_decode_budget_exhausted"
: invalid.length > 0
? "one_or_more_archives_invalid"
: prototypeKeyOmissions.size > 0
? "dictionary_entries_omitted"
: unexaminedSources.size > 0
? "serialized_archive_content_unexamined"
: counts.traversal.status === "unknown"
? "source_traversal_unknown"
: null,
: unexaminedSources.size > 0
? "serialized_archive_content_unexamined"
: counts.traversal.status === "unknown"
? "source_traversal_unknown"
: null,
examined: counts.attempted,
omitted: counts.omitted,
},
@@ -560,9 +537,6 @@ const finalizeHierarchyCoverage = (
: [
`Some Interface Builder archives could not be decoded: ${invalid.slice(0, 16).join("; ")}`,
]),
...[...prototypeKeyOmissions].map(
([path, count]) => `${path}: ${omittedPrototypeKeysLimitation(count)}`,
),
...[...unknownDates].map(
([path, count]) => `${path}: ${unknownDateLimitation(count)}`,
),
@@ -1075,15 +1049,13 @@ const parseXmlWithinDecodeBudget = (text: string) => {
}
};
/** Project decoded plist data and dates while retaining omitted-key coverage. */
/** Project decoded plist data and dates while retaining scalar coverage. */
const decodePlist = (
bytes: Buffer,
xmlText: string | undefined,
binaryPrototypeKeyObserved: boolean,
scalarPlan: BinaryPlistScalarDecodePlan,
scalarPlan: PlistDecodePreflight,
): {
readonly value: JsonValue;
readonly omittedPrototypeKeys: number | null;
readonly unknownDateCount: number;
readonly unknownScalarCount: number;
readonly numberLiterals: PlistNumberLiterals;
@@ -1092,22 +1064,15 @@ const decodePlist = (
const binaryScalarMetadata = binary
? scanBinaryPlistScalars(bytes, scalarPlan)
: undefined;
const { value, omittedPrototypeKeys, numberLiterals } = binary
const { value, numberLiterals } = binary
? {
value: retainBinaryPlistUidIdentity(
parseBinary(
decodeBinaryPlistScalars(bytes, binaryScalarMetadata!, scalarPlan),
),
scalarPlan,
),
omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0,
value: decodeBinaryPlistValue(bytes, scalarPlan, binaryScalarMetadata),
numberLiterals: binaryScalarMetadata!.literals,
}
: parseXmlWithinDecodeBudget(xmlText ?? decodeXmlPlistText(bytes));
const projected = projectPlistValue(value);
return {
value: projected.value,
omittedPrototypeKeys,
unknownDateCount: projected.unknownDateCount,
unknownScalarCount: projected.unknownScalarCount,
numberLiterals,
@@ -82,10 +82,9 @@ describe("inert XML declaration examples in Interface Builder archives", () => {
({ xml }) => {
expect(() => parseXmlPropertyList(xml)).not.toThrow();
expect(
preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024),
).toMatchObject({
binaryPrototypeKeyObserved: false,
});
preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024)
.estimatedBytes,
).toBeGreaterThan(0);
},
);
@@ -158,8 +157,9 @@ describe("inert XML declaration examples in Interface Builder archives", () => {
const xml = archive("Build").replace("<plist", `${declaration}<plist`);
expect(() => parseXmlPropertyList(xml)).not.toThrow();
expect(
preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024),
).toMatchObject({ binaryPrototypeKeyObserved: false });
preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024)
.estimatedBytes,
).toBeGreaterThan(0);
});
});
+61 -46
View File
@@ -232,27 +232,29 @@ describe("inert keyed archive decoding", () => {
expect.objectContaining({ source: 1, target: 2, status: "resolved" }),
);
});
it("reports an XML archive dictionary keyed __proto__ as omitted", () => {
const xml = build({ ...archive, $objects: ["$null", "value"] }).replace(
"<key>$top</key>",
"<key>__proto__</key><string>x</string><key>$top</key>",
);
it("preserves an XML object dictionary keyed __proto__", () => {
const xml = build({
...archive,
$objects: ["$null", { prototypeKey: "x" }],
}).replace("<key>prototypeKey</key>", "<key>__proto__</key>");
const graph = decodeKeyedArchiveBytes(Buffer.from(xml), {
offset: 0,
limit: 2,
});
expect(graph.objects.map(({ value }) => value)).toEqual(["$null", "value"]);
expect(graph.limitations).toContain(
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
);
expect(graph.objects.map(({ value }) => value)).toEqual([
"$null",
{ ["__proto__"]: "x" },
]);
expect(
graph.limitations.some((value) => value.includes("keyed __proto__")),
).toBe(false);
});
it("reports a binary archive dictionary keyed __proto__ as omitted", () => {
it("preserves a binary object dictionary keyed __proto__", () => {
const placeholder = "proto_key";
const encoded = Buffer.from(
buildBinary({
...archive,
[placeholder]: "hidden",
$objects: ["$null", "value"],
$objects: ["$null", { [placeholder]: "hidden" }],
}),
);
const start = encoded.indexOf(placeholder);
@@ -261,9 +263,10 @@ describe("inert keyed archive decoding", () => {
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.",
);
expect(graph.objects[1]?.value).toEqual({ ["__proto__"]: "hidden" });
expect(
graph.limitations.some((value) => value.includes("keyed __proto__")),
).toBe(false);
});
it("does not report a binary __proto__ value as an omitted key", () => {
const graph = decodeKeyedArchiveBytes(
@@ -276,9 +279,9 @@ describe("inert keyed archive decoding", () => {
),
{ offset: 0, limit: 2 },
);
expect(graph.limitations).not.toContain(
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
);
expect(
graph.limitations.some((value) => value.includes("keyed __proto__")),
).toBe(false);
});
it("rejects missing roots, malformed plist, and non-keyed archives", () => {
expect(() =>
@@ -447,6 +450,10 @@ describe("binary plist reference preflight", () => {
[2, "$null"],
]);
});
});
describe("binary plist dictionary materialization", () => {
const selection = { offset: 0, limit: 10 };
it("recognizes a shared key object used by separate dictionaries", () => {
const graph = decodeKeyedArchiveBytes(
@@ -460,18 +467,24 @@ describe("binary plist reference preflight", () => {
]),
selection,
);
expect(graph.limitations).toContain(
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
);
expect(graph.objects.map(({ value }) => value)).toEqual([
{ ["__proto__"]: "first" },
{ ["__proto__"]: "second" },
]);
expect(
graph.limitations.some((value) => value.includes("keyed __proto__")),
).toBe(false);
});
it("reports binary omission presence without claiming a count", () => {
it("preserves nested keys while duplicate entries retain the last value", () => {
const placeholder = "proto_key";
const nested = Buffer.from(
buildBinary({
...archive,
[placeholder]: { [placeholder]: "hidden", visible: true },
$objects: ["$null", "value"],
$objects: [
"$null",
{ [placeholder]: { [placeholder]: "hidden", visible: true } },
],
}),
);
let index = nested.indexOf(placeholder);
@@ -480,9 +493,14 @@ describe("binary plist reference preflight", () => {
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.",
);
expect(nestedGraph.objects[1]?.value).toEqual({
["__proto__"]: { ["__proto__"]: "hidden", visible: true },
});
expect(
nestedGraph.limitations.some((value) =>
value.includes("keyed __proto__"),
),
).toBe(false);
const shadowedValue = keyedRoot([
plistArray([9]),
@@ -497,12 +515,7 @@ describe("binary plist reference preflight", () => {
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.",
);
expect(shadowedGraph.objects[0]?.value).toEqual({ shadow: "replacement" });
});
it("detects UTF-16 binary dictionary keys", () => {
@@ -515,9 +528,10 @@ describe("binary plist reference preflight", () => {
]),
selection,
);
expect(graph.limitations).toContain(
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
);
expect(graph.objects[0]?.value).toEqual({ ["__proto__"]: "hidden" });
expect(
graph.limitations.some((value) => value.includes("keyed __proto__")),
).toBe(false);
});
it("detects extended-length UTF-16 binary dictionary keys", () => {
@@ -530,9 +544,10 @@ describe("binary plist reference preflight", () => {
]),
selection,
);
expect(graph.limitations).toContain(
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
);
expect(graph.objects[0]?.value).toEqual({ ["__proto__"]: "hidden" });
expect(
graph.limitations.some((value) => value.includes("keyed __proto__")),
).toBe(false);
});
});
@@ -893,7 +908,7 @@ it("preserves exact integers in XML authored UID dictionaries", () => {
expect(graph.total_references).toBe(1);
});
it("preserves XML dictionary identity when prototype-key omission leaves a CF$UID field", () => {
it("preserves XML dictionary identity beside an authored CF$UID field", () => {
const xml = build({
$archiver: "NSKeyedArchiver",
$objects: [
@@ -907,7 +922,9 @@ it("preserves XML dictionary identity when prototype-key omission leaves a CF$UI
offset: 0,
limit: 10,
});
expect(graph.objects[1]?.value).toEqual({ authoredRecord: { CF$UID: 2 } });
expect(graph.objects[1]?.value).toEqual({
authoredRecord: { CF$UID: 2, ["__proto__"]: "authored" },
});
expect(graph.total_references).toBe(2);
expect(graph.references).toEqual([
expect.objectContaining({
@@ -923,9 +940,7 @@ it("preserves XML dictionary identity when prototype-key omission leaves a CF$UI
status: "resolved",
}),
]);
expect(graph.limitations).toContainEqual(
expect.stringMatching(
/\b1 dictionary entry\b.*\b__proto__\b.*\bomitted\b/u,
),
);
expect(
graph.limitations.some((value) => value.includes("keyed __proto__")),
).toBe(false);
});
+8 -23
View File
@@ -1,7 +1,8 @@
import { createHash } from "node:crypto";
import { join } from "node:path";
import { parseBinary } from "plist";
import { z } from "zod";
import { decodeBinaryPlistValue } from "./BinaryPlistMaterializer.js";
import {
AnalysisAccessDeniedError,
AnalysisInputError,
@@ -16,15 +17,10 @@ import {
} from "../../domain/apple/plistValue.js";
import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js";
import {
decodeBinaryPlistScalars,
retainBinaryPlistUidIdentity,
scanBinaryPlistScalars,
UnsupportedBinaryPlistUidError,
} from "./PlistScalars.js";
import {
omittedPrototypeKeysLimitation,
parseXmlPropertyList,
} from "../../domain/propertyListKeys.js";
import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
import {
KeyedArchiveKindError,
keyedArchiveInputSchema,
@@ -68,8 +64,8 @@ export const decodeKeyedArchiveBytes = (
);
const binary = hasBinaryPlistSignature(bytes);
const xmlText = binary ? undefined : decodeXmlPlistText(bytes);
// plist.parseBinary recurses on every object reference and copies shared
// containers, so a few hundred bytes can exhaust memory or the stack.
// Scalar decoding and source container materialization expand shared references,
// so a few hundred bytes can exhaust memory or the stack without preflight.
// RangeError reports a resource limit; a reference cycle is malformed.
const binaryExpansion = binary
? estimateBinaryPlistExpansion(bytes, MAX_DECODE_BYTES, {
@@ -91,20 +87,12 @@ export const decodeKeyedArchiveBytes = (
const parsed =
binaryExpansion !== undefined
? {
value: retainBinaryPlistUidIdentity(
parseBinary(
decodeBinaryPlistScalars(
bytes,
binaryScalarMetadata!,
binaryExpansion!,
),
),
value: decodeBinaryPlistValue(
bytes,
binaryExpansion,
binaryScalarMetadata,
),
numberLiterals: binaryScalarMetadata!.literals,
omittedPrototypeKeys: binaryExpansion.binaryPrototypeKeyObserved
? null
: 0,
}
: parseXmlPropertyList(xmlText ?? "");
const {
@@ -135,9 +123,6 @@ export const decodeKeyedArchiveBytes = (
limitations: [
...graph.limitations,
...numbers.limitations(),
...(parsed.omittedPrototypeKeys === 0
? []
: [omittedPrototypeKeysLimitation(parsed.omittedPrototypeKeys)]),
...(nonFiniteRealCount === 0
? []
: [nonFiniteRealLimitation(nonFiniteRealCount)]),
+3 -3
View File
@@ -7,7 +7,6 @@ import { projectPlistValue } from "../../domain/apple/plistValue.js";
import {
binaryDateRequiresExactSeconds,
decodeBinaryPlistScalars,
decodeBinaryPlistUids,
scanBinaryPlistScalars,
} from "./PlistScalars.js";
import {
@@ -262,9 +261,10 @@ describe("binary plist UID widths", () => {
? []
: [{ offset: 8, width: normalizedWidth }],
);
const compatible = decodeBinaryPlistUids(
const compatible = decodeBinaryPlistScalars(
bytes,
plan.uidOffsetsToNormalize,
scanBinaryPlistScalars(bytes, plan),
plan,
);
if (normalizedWidth === undefined) expect(compatible).toBe(bytes);
else expect(compatible[8 + width + 1]).toBe(0x80 | (normalizedWidth - 1));
+18 -79
View File
@@ -1,4 +1,3 @@
import { markPlistDictionary } from "../../domain/apple/plistUid.js";
import {
createPlistNumberCollector,
type PlistNumberLiterals,
@@ -33,15 +32,7 @@ const containsSortedOffset = (
return false;
};
/** A bounded linked value path retained by the existing binary reference walk. */
export interface BinaryPlistUidPath {
readonly parent: BinaryPlistUidPath | null;
readonly key: string | number;
}
export interface BinaryPlistScalarDecodePlan {
/** Present only when an archive reader requested serialized UID identity. */
readonly binaryUidPaths?: readonly (BinaryPlistUidPath | null)[];
readonly reachableDateOffsets: readonly number[];
readonly overlappingDateOffsets: readonly number[];
readonly reachableWideIntegerOffsets: readonly number[];
@@ -172,21 +163,6 @@ export const binaryPlistCloneOffsetWidth = (
);
};
/** Normalize unsupported binary-plist UID widths for the pinned parser. */
export const decodeBinaryPlistUids = (
bytes: Buffer,
uidOffsets: readonly { offset: number; width: number }[],
): Buffer => {
if (uidOffsets.length === 0) return bytes;
const replacements = new Map(
uidOffsets.map(({ offset, width }) => [
offset,
binaryPlistUidRecord(bytes, offset, width),
]),
);
return cloneBinaryPlistObjects(bytes, replacements);
};
const APPLE_EPOCH_MILLISECONDS = 978_307_200_000;
/** Whether Date's millisecond projection would lose finite, in-range source seconds. */
@@ -391,49 +367,6 @@ export const scanBinaryPlistScalars = (
return result(incomplete);
};
/** Restore UID identity from validated source locations, never from dictionary shape. */
export const retainBinaryPlistUidIdentity = (
value: unknown,
plan: BinaryPlistScalarDecodePlan,
): unknown => {
if (plan.binaryUidPaths === undefined) return value;
const pending: unknown[] = [value];
while (pending.length > 0) {
const item = pending.pop();
if (Array.isArray(item)) {
for (const child of item) pending.push(child);
} else if (
item !== null &&
typeof item === "object" &&
!(item instanceof Date) &&
!(item instanceof Uint8Array)
) {
markPlistDictionary(item);
for (const child of Object.values(item)) pending.push(child);
}
}
for (let path of plan.binaryUidPaths) {
const keys: (string | number)[] = [];
while (path !== null) {
keys.push(path.key);
path = path.parent;
}
let item: unknown = value;
for (let index = keys.length - 1; index >= 0; index -= 1) {
if (item === null || typeof item !== "object") {
item = undefined;
break;
}
item = Object.getOwnPropertyDescriptor(item, keys[index]!)?.value;
}
if (item !== null && typeof item === "object") {
const uid = Object.getOwnPropertyDescriptor(item, "UID")?.value;
if (typeof uid === "number") markPlistDictionary(item, uid);
}
}
return value;
};
/** Preserve source scalars through plist@5's lossy Number and Date parser. */
export const decodeBinaryPlistScalars = (
bytes: Buffer,
@@ -517,6 +450,23 @@ export const decodeBinaryPlistScalars = (
return compatible;
};
/** Iterate source offset declarations using the same uint64 interpretation as normalization. */
export function* binaryPlistObjectOffsets(bytes: Buffer): Generator<bigint> {
const trailer = bytes.length - 32;
const width = bytes[trailer + 6] ?? 0;
const count = Number(bytes.readBigUInt64BE(trailer + 8));
const table = Number(bytes.readBigUInt64BE(trailer + 24));
for (let object = 0; object < count; object += 1) {
let offset = 0n;
for (let byte = 0; byte < width; byte += 1)
offset = BigInt.asUintN(
64,
(offset << 8n) | BigInt(bytes[table + object * width + byte] ?? 0),
);
yield offset;
}
}
const cloneBinaryPlistObjects = (
bytes: Buffer,
replacements: ReadonlyMap<number, Buffer>,
@@ -527,18 +477,7 @@ const cloneBinaryPlistObjects = (
const offsetSize = bytes[trailerOffset + 6] ?? 0;
const objectCount = Number(bytes.readBigUInt64BE(trailerOffset + 8));
const oldTableOffset = Number(bytes.readBigUInt64BE(trailerOffset + 24));
const readOffset = (offset: number): bigint => {
let value = 0n;
for (let index = 0; index < offsetSize; index += 1)
value = BigInt.asUintN(
64,
(value << 8n) | BigInt(bytes[offset + index] ?? 0),
);
return value;
};
const offsets = Array.from({ length: objectCount }, (_, index) =>
readOffset(oldTableOffset + index * offsetSize),
);
const offsets = [...binaryPlistObjectOffsets(bytes)];
const cloneEntries = [...replacements.entries()];
const replacementLengths = cloneEntries.map(([, record]) => record.length);
const newOffsetSize =
+71 -83
View File
@@ -5,7 +5,6 @@ import {
binaryDateRequiresExactSeconds,
binaryPlistObjectEnd,
binaryPlistUidNormalizationWidth,
type BinaryPlistUidPath,
} from "./PlistScalars.js";
/** Preflight the representation created by the pinned plist decoder. */
@@ -40,7 +39,7 @@ export const preflightPropertyListDecode = (
budget,
fail,
),
binaryPrototypeKeyObserved: false,
datePrecisionOverlapCount: 0,
reachableDateOffsets: [],
overlappingDateOffsets: [],
@@ -173,11 +172,25 @@ export interface BinaryPlistExpansionOptions {
readonly bounds?: "strict" | "decoder";
}
export interface BinaryPlistContainer {
readonly object: number;
readonly kind: "array" | "dictionary";
readonly references: readonly number[];
readonly keys?: readonly string[];
}
/** Validated source references, separate from upstream scalar conversion. */
export interface BinaryPlistValuePlan {
readonly topObject: number;
readonly scalars: readonly { object: number; uid: boolean }[];
readonly containers: readonly BinaryPlistContainer[];
readonly uidIdentity: boolean;
}
/** Preflight size and binary-key observations; XML keys are inspected by decode. */
export interface PlistDecodePreflight {
readonly binaryUidPaths?: readonly (BinaryPlistUidPath | null)[];
readonly binaryValuePlan?: BinaryPlistValuePlan;
readonly estimatedBytes: number;
readonly binaryPrototypeKeyObserved: boolean;
/** Number of expanded references to precision-unknown overlapping dates. */
readonly datePrecisionOverlapCount: number;
/** Unique reachable date records in the selected value graph. */
@@ -204,8 +217,6 @@ export interface PlistDecodePreflight {
readonly normalizedReferenceSize: number;
}
const PROTOTYPE_KEY = "__proto__";
type BinaryObjectSpan = { readonly offset: number; readonly end: number };
const findOverlappingScalarOffsets = (
@@ -236,8 +247,8 @@ const findOverlappingScalarOffsets = (
};
/**
* Count every expanded binary-plist reference before calling plist.parseBinary,
* which recurses on each reference and copies shared containers. Malformed
* Validate and budget every expanded source reference before scalar decoding
* and independent container materialization. Malformed
* structure throws TypeError; resource and cycle failures use `fail`.
*/
export const estimateBinaryPlistExpansion = (
@@ -298,15 +309,15 @@ export const estimateBinaryPlistExpansion = (
);
if (normalizedReferenceSize === undefined)
throw new TypeError("binary plist object reference count is invalid");
// Reserve source metadata and reachable-offset indexes for scalar projection.
// Without projection, the traversal retains only structural validation state.
const objectBytes = numberProjection || uidProjection ? 320 : 16;
// Reserve source container/reference facts, scalar indexing, and the scalar
// array/table overhead; projection also retains numeric/UID source metadata.
const objectBytes = numberProjection || uidProjection ? 512 : 256;
const expandedNodeBytes = numberProjection || uidProjection ? 224 : 96;
// Scalar projection may require a same-sized parser-compatible input copy
// for wide integers and sub-millisecond dates. Reserve before allocation.
// for wide integers and sub-millisecond dates. The scalar array decoder keeps
// another input-sized copy; reserve both before allocation.
let estimate =
bytes.length * (numberProjection ? 3 : 2) + objectCount * objectBytes;
let binaryPrototypeKeyObserved = false;
bytes.length * (numberProjection ? 4 : 3) + objectCount * objectBytes;
if (estimate > maxBytes)
throw fail("expansion", `binary plist object table exceeds ${budget}`);
@@ -316,17 +327,16 @@ export const estimateBinaryPlistExpansion = (
depth: number;
exit: boolean;
dictionaryKey: boolean;
path?: BinaryPlistUidPath | null;
}> = [
{
object: topObject,
depth: 0,
exit: false,
dictionaryKey: false,
...(uidProjection ? { path: null } : {}),
},
];
const binaryUidPaths: (BinaryPlistUidPath | null)[] = [];
const scalarObjects = new Map<number, { object: number; uid: boolean }>();
const sourceContainers = new Map<number, BinaryPlistContainer>();
const dictionaryKeys = new Map<number, string>();
const readDictionaryKey = (object: number): string => {
const prior = dictionaryKeys.get(object);
@@ -402,6 +412,11 @@ export const estimateBinaryPlistExpansion = (
if (numberProjection) reachableObjectOffsets.add(offset);
const marker = bytes[offset] ?? 0;
const type = marker >> 4;
if (type !== 10 && type !== 13)
scalarObjects.set(entry.object, {
object: entry.object,
uid: type === 8,
});
if (entry.dictionaryKey && type !== 5 && type !== 6)
throw new TypeError("binary plist dictionary key is not a string");
if (type === 3) {
@@ -424,7 +439,6 @@ export const estimateBinaryPlistExpansion = (
const uidWidth = (marker & 0x0f) + 1;
if (offset + 1 + uidWidth > objectsEnd)
throw new TypeError("binary plist UID object is truncated");
if (entry.path !== undefined) binaryUidPaths.push(entry.path);
const normalizedWidth = binaryPlistUidNormalizationWidth(bytes, offset);
if (normalizedWidth !== undefined)
uidWidthsToNormalize.set(offset, normalizedWidth);
@@ -462,26 +476,6 @@ 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(
@@ -503,28 +497,24 @@ export const estimateBinaryPlistExpansion = (
"expansion",
`binary plist reference expansion exceeds ${budget}`,
);
const parsedReferences: number[] | undefined =
referenceSize !== normalizedReferenceSize &&
!containersToNormalize.has(offset)
? []
: undefined;
// A duplicate dictionary key exposes only its final value in plist@5.
// Keep provenance for that value while still validating every source ref.
const valueKeys = new Map<number, string>();
if (entry.path !== undefined && type === 13) {
const lastKeyIndexes = new Map<string, number>();
for (let index = 0; index < size; index += 1) {
const reference = readInteger(
cursor + index * referenceSize,
referenceSize,
);
if (reference < 0 || reference >= objectCount)
throw new TypeError("binary plist reference is invalid");
lastKeyIndexes.set(readDictionaryKey(reference), index);
}
for (const [key, index] of lastKeyIndexes)
if (key !== PROTOTYPE_KEY) valueKeys.set(size + index, key);
}
const references = Array.from({ length: childCount }, (_, index) => {
const reference = readInteger(
cursor + index * referenceSize,
referenceSize,
);
if (reference < 0 || reference >= objectCount)
throw new TypeError("binary plist reference is invalid");
return reference;
});
if (!sourceContainers.has(entry.object))
sourceContainers.set(entry.object, {
object: entry.object,
kind: type === 13 ? "dictionary" : "array",
references: type === 13 ? references.slice(size) : references,
...(type === 13
? { keys: references.slice(0, size).map(readDictionaryKey) }
: {}),
});
active.add(entry.object);
pending.push({
object: entry.object,
@@ -533,33 +523,22 @@ export const estimateBinaryPlistExpansion = (
dictionaryKey: false,
});
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");
if (parsedReferences !== undefined) parsedReferences[index] = reference;
const reference = references[index]!;
pending.push({
object: reference,
depth: entry.depth + 1,
exit: false,
dictionaryKey: type === 13 && index < size,
...(entry.path === undefined || (type === 13 && !valueKeys.has(index))
? {}
: {
path: {
parent: entry.path,
key: type === 13 ? valueKeys.get(index)! : index,
},
}),
});
}
if (parsedReferences !== undefined)
if (
referenceSize !== normalizedReferenceSize &&
!containersToNormalize.has(offset)
)
containersToNormalize.set(offset, {
offset,
headerLength: cursor - offset,
references: parsedReferences,
references,
});
pendingObjectCount += childCount;
}
@@ -597,7 +576,7 @@ export const estimateBinaryPlistExpansion = (
if (cloneLengths.length > 0 || normalizedOffsetSize !== offsetSize) {
const cloneBytes = cloneLengths.reduce((sum, length) => sum + length, 0);
estimate +=
cloneBytes + objectCount * (normalizedOffsetSize - offsetSize);
2 * (cloneBytes + objectCount * (normalizedOffsetSize - offsetSize));
if (estimate > maxBytes)
throw fail(
"expansion",
@@ -606,8 +585,12 @@ export const estimateBinaryPlistExpansion = (
}
return {
estimatedBytes: estimate,
...(uidProjection ? { binaryUidPaths } : {}),
binaryPrototypeKeyObserved,
binaryValuePlan: {
topObject,
scalars: [...scalarObjects.values()],
containers: [...sourceContainers.values()],
uidIdentity: uidProjection,
},
datePrecisionOverlapCount: 0,
reachableDateOffsets: [],
overlappingDateOffsets: [],
@@ -664,7 +647,8 @@ export const estimateBinaryPlistExpansion = (
const normalizedOffsetSize = normalizeOffsetWidth(cloneLengths);
if (cloneLengths.length > 0 || normalizedOffsetSize !== offsetSize) {
const cloneBytes = cloneLengths.reduce((sum, length) => sum + length, 0);
estimate += cloneBytes + objectCount * (normalizedOffsetSize - offsetSize);
estimate +=
2 * (cloneBytes + objectCount * (normalizedOffsetSize - offsetSize));
if (estimate > maxBytes)
throw fail(
"expansion",
@@ -677,8 +661,12 @@ export const estimateBinaryPlistExpansion = (
).length;
return {
estimatedBytes: estimate,
...(uidProjection ? { binaryUidPaths } : {}),
binaryPrototypeKeyObserved,
binaryValuePlan: {
topObject,
scalars: [...scalarObjects.values()],
containers: [...sourceContainers.values()],
uidIdentity: uidProjection,
},
datePrecisionOverlapCount,
reachableDateOffsets: [...reachableDateOffsets].sort(
(left, right) => left - right,