mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
refactor(plist): centralize binary layout and offset ownership
This commit is contained in:
1 parent
d27e3f8e89
commit
13fba271e6
6 files changed
+223
-72
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user