refactor(plist): centralize binary layout and offset ownership

This commit is contained in:
morluto committed 2026-10-26 23:51:33 +08:00
1 parent d27e3f8e89
commit 13fba271e6
6 files changed
+223 -72

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
import { err, ok, type Result } from "../../domain/result.js";
/**
* Preserve REA's source integer interpretation, including uint64
* wrapping for unusual widths. Callers own byte-range and safe-number admission.
*/
export const readBinaryPlistUnsigned64 = (
bytes: Uint8Array,
offset: number,
width: number,
): bigint => {
let value = 0n;
for (let index = 0; index < width; index += 1)
value = BigInt.asUintN(
64,
(value << 8n) | BigInt(bytes[offset + index] ?? 0),
);
return value;
};
/** Raw declarations tied to one source or decoder-copy byte representation. */
export interface BinaryPlistLayout {
readonly trailerOffset: number;
readonly offsetWidth: number;
readonly referenceWidth: number;
readonly objectCount: bigint;
readonly topObject: bigint;
readonly offsetTable: bigint;
readonly objectOffset: (object: number) => bigint;
readonly objectOffsets: () => Generator<bigint>;
}
/** Observe declarations only; signature, bounds, capacities and reachability remain caller policy. */
export const readBinaryPlistLayout = (
bytes: Uint8Array,
): Result<
BinaryPlistLayout,
{ readonly _tag: "InvalidBinaryPlistLayout"; readonly reason: "truncated" }
> => {
if (bytes.byteLength < 32)
return err({ _tag: "InvalidBinaryPlistLayout", reason: "truncated" });
const trailerOffset = bytes.byteLength - 32;
const offsetWidth = bytes[trailerOffset + 6]!;
const referenceWidth = bytes[trailerOffset + 7]!;
const objectCount = readBinaryPlistUnsigned64(bytes, trailerOffset + 8, 8);
const offsetTable = readBinaryPlistUnsigned64(bytes, trailerOffset + 24, 8);
const objectOffset = (object: number): bigint =>
readBinaryPlistUnsigned64(
bytes,
Number(offsetTable) + object * offsetWidth,
offsetWidth,
);
return ok({
trailerOffset,
offsetWidth,
referenceWidth,
objectCount,
topObject: readBinaryPlistUnsigned64(bytes, trailerOffset + 16, 8),
offsetTable,
objectOffset,
*objectOffsets() {
for (let object = 0; object < Number(objectCount); object += 1)
yield objectOffset(object);
},
});
};
@@ -1,5 +1,8 @@
import { expect, it } from "vitest";
import { binaryPlistObjectTable } from "../../../tests/fixtures/binaryPlistObjectTable.js";
import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js";
import { projectPlistValue } from "../../domain/apple/plistValue.js";
import { scanBinaryPlistScalars } from "./PlistScalars.js";
import { plistUidValue } from "../../domain/apple/plistUid.js";
import { decodeBinaryPlistValue } from "./BinaryPlistMaterializer.js";
import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js";
@@ -80,3 +83,67 @@ it("keeps a source UID distinct from an ordinary UID-shaped dictionary", () => {
expect(plistUidValue(value[0])).toBe(7);
expect(plistUidValue(value[1])).toBeUndefined();
});
it("materializes normalized three-byte tables from a nonzero root without losing source numbers", () => {
const tableOffset = 36;
const bytes = Buffer.alloc(tableOffset + 9 + 32);
bytes.write("bplist00");
bytes[8] = 0x14;
bytes.writeBigUInt64BE(1n, 9);
bytes.writeBigUInt64BE(2n, 17);
bytes[25] = 0xa2;
bytes.writeUIntBE(0, 26, 3);
bytes.writeUIntBE(2, 29, 3);
bytes[32] = 0x82;
bytes.writeUIntBE(0xabcdef, 33, 3);
for (const [object, offset] of [8, 25, 32].entries())
bytes.writeUIntBE(offset, tableOffset + object * 3, 3);
const trailer = bytes.length - 32;
bytes[trailer + 6] = 3;
bytes[trailer + 7] = 3;
bytes.writeBigUInt64BE(3n, trailer + 8);
bytes.writeBigUInt64BE(1n, trailer + 16);
bytes.writeBigUInt64BE(BigInt(tableOffset), trailer + 24);
const original = Buffer.from(bytes);
const plan = estimateBinaryPlistExpansion(bytes, 1024 * 1024, {
numberProjection: true,
uidProjection: true,
});
const metadata = scanBinaryPlistScalars(bytes, plan);
expect(metadata.wideIntegers).toEqual([
{ offset: 8, value: 18446744073709551618n },
]);
expect(metadata.literals.incomplete).toBe(false);
const decoded = decodeBinaryPlistValue(bytes, plan, metadata) as unknown[];
expect(
createPlistNumberProjection(metadata.literals).project(
projectPlistValue(decoded[0]).value,
),
).toEqual({
$plist_type: "integer",
decimal: "18446744073709551618",
});
expect(plistUidValue(decoded[1])).toBe(0xabcdef);
expect(decoded).toHaveLength(2);
expect(bytes.equals(original)).toBe(true);
});
it("keeps uint64 wrapping for nine-byte offset and reference declarations", () => {
const bytes = Buffer.alloc(70);
bytes.write("bplist00");
bytes.set([0x10, 7, 0xa1], 8);
bytes[11] = 0xff;
bytes[20] = 0xff;
bytes.writeBigUInt64BE(8n, 21);
bytes[29] = 0xff;
bytes.writeBigUInt64BE(10n, 30);
const trailer = 38;
bytes[trailer + 6] = 9;
bytes[trailer + 7] = 9;
bytes.writeBigUInt64BE(2n, trailer + 8);
bytes.writeBigUInt64BE(1n, trailer + 16);
bytes.writeBigUInt64BE(20n, trailer + 24);
const original = Buffer.from(bytes);
expect(decode(bytes)).toEqual([7]);
expect(bytes.equals(original)).toBe(true);
});
+10 -5
View File
@@ -1,7 +1,7 @@
import { readBinaryPlistLayout } from "./BinaryPlistLayout.js";
import { parseBinary } from "plist";
import { markPlistDictionary } from "../../domain/apple/plistUid.js";
import {
binaryPlistObjectOffsets,
decodeBinaryPlistScalars,
scanBinaryPlistScalars,
type BinaryPlistScalarMetadata,
@@ -13,9 +13,14 @@ 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));
// Normalization may relocate objects and change both widths. These facts
// belong to the compatible decoder copy, not the original source plan.
const compatibleLayoutResult = readBinaryPlistLayout(bytes);
if (!compatibleLayoutResult.ok)
throw new RangeError("binary plist trailer is truncated");
const compatibleLayout = compatibleLayoutResult.value;
const referenceWidth = compatibleLayout.referenceWidth;
const objectCount = Number(compatibleLayout.objectCount);
const rootOffset = bytes.length;
const rootLength = 10 + objects.length * referenceWidth;
const tableOffset = rootOffset + rootLength;
@@ -33,7 +38,7 @@ const scalarArrayInput = (
}
}
let object = 0;
for (const offset of binaryPlistObjectOffsets(bytes)) {
for (const offset of compatibleLayout.objectOffsets()) {
output.writeBigUInt64BE(offset, tableOffset + object * 8);
object += 1;
}
+26 -1
View File
@@ -488,7 +488,15 @@ describe("binary plist shared object-table entries", () => {
const table = Number(bytes.readBigUInt64BE(bytes.length - 8));
bytes[table + 2] = 7;
expect(bytes.subarray(0, 8).toString("ascii")).toBe("bplist00");
expect(scanBinaryPlistScalars(bytes).literals.incomplete).toBe(true);
const metadata = scanBinaryPlistScalars(bytes);
expect(metadata.literals.incomplete).toBe(true);
expect(metadata.literals.integers).toEqual(
new Map([
[9007199254740992, new Set(["9007199254740993"])],
[Number(1152921504606846979n), new Set(["1152921504606846979"])],
]),
);
expect(metadata.literals.unsafeIntegerLiterals).toBe(2);
});
});
@@ -586,3 +594,20 @@ describe("binary plist scalar clone budgeting", () => {
expect(bytes.readDoubleBE(14)).toBe(0.1005);
});
});
it.each([4n, 0xffffffffffffffffn])(
"scans source scalars independently of invalid top object %s",
(topObject) => {
const bytes = sharedScalarEntries(int64(9007199254740993n));
const expected = scanBinaryPlistScalars(bytes);
bytes.writeBigUInt64BE(topObject, bytes.length - 16);
expect(scanBinaryPlistScalars(bytes)).toEqual(expected);
expect(expected.literals.incomplete).toBe(false);
expect(expected.literals.unsafeIntegerLiterals).toBe(2);
expect(() =>
estimateBinaryPlistExpansion(bytes, 1024 * 1024, {
numberProjection: true,
}),
).toThrow("binary plist trailer or object table is invalid");
},
);
+33 -48
View File
@@ -1,3 +1,8 @@
import {
readBinaryPlistLayout,
readBinaryPlistUnsigned64,
type BinaryPlistLayout,
} from "./BinaryPlistLayout.js";
import {
createPlistNumberCollector,
type PlistNumberLiterals,
@@ -196,10 +201,7 @@ export const binaryPlistObjectEnd = (
const width = 1 << (extendedMarker & 0x0f);
if (width > 8 || cursor + 1 + width > objectEnd) return undefined;
cursor += 1;
let extendedLength = 0n;
for (let index = 0; index < width; index += 1)
extendedLength =
(extendedLength << 8n) | BigInt(bytes[cursor + index] ?? 0);
const extendedLength = readBinaryPlistUnsigned64(bytes, cursor, width);
if (extendedLength > BigInt(Number.MAX_SAFE_INTEGER)) return undefined;
length = Number(extendedLength);
cursor += width;
@@ -247,11 +249,17 @@ export const scanBinaryPlistScalars = (
});
if (bytes.length < 40 || !hasBinaryPlistSignature(bytes)) return result(true);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const trailer = bytes.length - 32;
const offsetWidth = bytes[trailer + 6] ?? 0;
const sourceLayoutResult = readBinaryPlistLayout(bytes);
if (!sourceLayoutResult.ok) return result(true);
const sourceLayout = sourceLayoutResult.value;
const {
trailerOffset: trailer,
offsetWidth,
referenceWidth,
objectCount: countRaw,
offsetTable: tableRaw,
} = sourceLayout;
if (offsetWidth < 1) return result(true);
const countRaw = view.getBigUint64(trailer + 8);
const tableRaw = view.getBigUint64(trailer + 24);
const safeMaximum = BigInt(Number.MAX_SAFE_INTEGER);
if (countRaw === 0n || countRaw > safeMaximum || tableRaw > safeMaximum)
return result(true);
@@ -260,23 +268,12 @@ export const scanBinaryPlistScalars = (
const capacity = Math.floor((trailer - offsetTable) / offsetWidth);
if (countRaw > BigInt(capacity)) return result(true);
const count = Number(countRaw);
const referenceWidth = bytes[trailer + 7] ?? 0;
if (referenceWidth < 1) return result(true);
const unsigned = (offset: number): number | undefined => {
if (offset < offsetTable || offset + offsetWidth > trailer)
return undefined;
let value = 0n;
for (let index = 0; index < offsetWidth; index += 1)
value = BigInt.asUintN(
64,
(value << 8n) | BigInt(bytes[offset + index] ?? 0),
);
return value > safeMaximum ? undefined : Number(value);
};
const objectOffsets: number[] = [];
let incomplete = false;
for (let index = 0; index < count; index += 1) {
const offset = unsigned(offsetTable + index * offsetWidth);
const rawOffset = sourceLayout.objectOffset(index);
const offset = rawOffset > safeMaximum ? undefined : Number(rawOffset);
if (offset === undefined || offset < 8 || offset >= offsetTable) {
incomplete = true;
continue;
@@ -405,8 +402,11 @@ export const decodeBinaryPlistScalars = (
replacement.writeDoubleBE(Number(value), 1);
replacements.set(offset, replacement);
}
const trailerOffset = bytes.length - 32;
const sourceReferenceSize = bytes[trailerOffset + 7] ?? 0;
const sourceLayoutResult = readBinaryPlistLayout(bytes);
if (!sourceLayoutResult.ok)
throw new RangeError("binary plist trailer is truncated");
const sourceLayout = sourceLayoutResult.value;
const sourceReferenceSize = sourceLayout.referenceWidth;
const normalizedReferenceSize =
scalarPlan.normalizedReferenceSize ?? sourceReferenceSize;
for (const container of scalarPlan.containersToNormalize ?? [])
@@ -415,8 +415,8 @@ export const decodeBinaryPlistScalars = (
binaryPlistContainerRecord(bytes, container, normalizedReferenceSize),
);
const needsTableNormalization =
(scalarPlan.normalizedOffsetSize ?? bytes[trailerOffset + 6] ?? 0) !==
(bytes[trailerOffset + 6] ?? 0) ||
(scalarPlan.normalizedOffsetSize ?? sourceLayout.offsetWidth) !==
sourceLayout.offsetWidth ||
normalizedReferenceSize !== sourceReferenceSize;
if (
reachableWideIntegers.length === 0 &&
@@ -430,8 +430,9 @@ export const decodeBinaryPlistScalars = (
? Buffer.from(bytes)
: cloneBinaryPlistObjects(
bytes,
sourceLayout,
replacements,
scalarPlan.normalizedOffsetSize ?? bytes[trailerOffset + 6] ?? 0,
scalarPlan.normalizedOffsetSize ?? sourceLayout.offsetWidth,
normalizedReferenceSize,
);
for (const { offset, value } of reachableWideIntegers) {
@@ -450,34 +451,18 @@ 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,
sourceLayout: BinaryPlistLayout,
replacements: ReadonlyMap<number, Buffer>,
normalizedOffsetSize?: number,
normalizedReferenceSize?: number,
): Buffer => {
const trailerOffset = bytes.length - 32;
const offsetSize = bytes[trailerOffset + 6] ?? 0;
const objectCount = Number(bytes.readBigUInt64BE(trailerOffset + 8));
const oldTableOffset = Number(bytes.readBigUInt64BE(trailerOffset + 24));
const offsets = [...binaryPlistObjectOffsets(bytes)];
const trailerOffset = sourceLayout.trailerOffset;
const offsetSize = sourceLayout.offsetWidth;
const objectCount = Number(sourceLayout.objectCount);
const oldTableOffset = Number(sourceLayout.offsetTable);
const offsets = [...sourceLayout.objectOffsets()];
const cloneEntries = [...replacements.entries()];
const replacementLengths = cloneEntries.map(([, record]) => record.length);
const newOffsetSize =
+21 -18
View File
@@ -1,3 +1,7 @@
import {
readBinaryPlistLayout,
readBinaryPlistUnsigned64,
} from "./BinaryPlistLayout.js";
import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js";
import {
@@ -265,24 +269,26 @@ export const estimateBinaryPlistExpansion = (
): PlistDecodePreflight => {
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;
const sourceLayoutResult = readBinaryPlistLayout(bytes);
if (!sourceLayoutResult.ok)
throw new TypeError("binary plist trailer is truncated");
const sourceLayout = sourceLayoutResult.value;
const {
trailerOffset: trailer,
offsetWidth: offsetSize,
referenceWidth: referenceSize,
} = sourceLayout;
if (offsetSize < 1 || referenceSize < 1)
throw new TypeError("binary plist trailer integer sizes are invalid");
const safeDeclaration = (value: bigint): number =>
value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : -1;
const readInteger = (offset: number, size: number): number => {
if (offset < 0 || size < 1 || offset + size > bytes.length) return -1;
let value = 0n;
for (let index = 0; index < size; index += 1)
value = BigInt.asUintN(
64,
(value << 8n) | BigInt(bytes[offset + index] ?? 0),
);
return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : -1;
return safeDeclaration(readBinaryPlistUnsigned64(bytes, offset, size));
};
const objectCount = readInteger(trailer + 8, 8);
const topObject = readInteger(trailer + 16, 8);
const offsetTable = readInteger(trailer + 24, 8);
const objectCount = safeDeclaration(sourceLayout.objectCount);
const topObject = safeDeclaration(sourceLayout.topObject);
const offsetTable = safeDeclaration(sourceLayout.offsetTable);
const tableEnd = bounds === "strict" ? trailer : bytes.length;
const objectsEnd = bounds === "strict" ? offsetTable : bytes.length;
if (
@@ -341,7 +347,7 @@ export const estimateBinaryPlistExpansion = (
const readDictionaryKey = (object: number): string => {
const prior = dictionaryKeys.get(object);
if (prior !== undefined) return prior;
const offset = readInteger(offsetTable + object * offsetSize, offsetSize);
const offset = safeDeclaration(sourceLayout.objectOffset(object));
if (offset < 8 || offset >= objectsEnd)
throw new TypeError("binary plist object offset is invalid");
const marker = bytes[offset] ?? 0;
@@ -403,10 +409,7 @@ export const estimateBinaryPlistExpansion = (
"expansion",
`binary plist reference expansion exceeds ${budget}`,
);
const offset = readInteger(
offsetTable + entry.object * offsetSize,
offsetSize,
);
const offset = safeDeclaration(sourceLayout.objectOffset(entry.object));
if (offset < 8 || offset >= objectsEnd)
throw new TypeError("binary plist object offset is invalid");
if (numberProjection) reachableObjectOffsets.add(offset);