Binary plist object tables may list one object offset more than once; plistlib reads each entry independently. The scalar scanner marked any repeated offset as incomplete metadata, which made the number projection drop every exact decimal in the archive. A keyed archive whose entries 10 and 11 share an Int64 9007199254740993 reported both as observed 9007199254740992 and rounded an unrelated 2^60+3 sibling, with only the generic incomplete-metadata caveat. A repeated valid offset carries the same source facts as its first visit, so observe it once and continue. Offsets that fail to parse still mark metadata incomplete. Shared 16-byte integers and dates are rewritten once in the decoder copy, which every entry then reads; the overlap plan already treats one shared offset as one span. Keyed archives and Interface Builder decoding share this scanner.
127 lines
4.1 KiB
TypeScript
127 lines
4.1 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { createTestTempDirectory } from "./temporaryDirectory.js";
|
|
|
|
const ascii = (value: string): Buffer => {
|
|
const bytes = Buffer.from(value, "ascii");
|
|
return bytes.length < 15
|
|
? Buffer.concat([Buffer.from([0x50 | bytes.length]), bytes])
|
|
: Buffer.concat([Buffer.from([0x5f, 0x10, bytes.length]), bytes]);
|
|
};
|
|
|
|
const integer128 = (value: bigint): Buffer => {
|
|
const bytes = Buffer.alloc(17);
|
|
bytes[0] = 0x14;
|
|
const encoded = BigInt.asUintN(128, value);
|
|
bytes.writeBigUInt64BE(encoded >> 64n, 1);
|
|
bytes.writeBigUInt64BE(BigInt.asUintN(64, encoded), 9);
|
|
return bytes;
|
|
};
|
|
|
|
const integer64 = (value: bigint): Buffer => {
|
|
const bytes = Buffer.alloc(9);
|
|
bytes[0] = 0x13;
|
|
bytes.writeBigInt64BE(value, 1);
|
|
return bytes;
|
|
};
|
|
|
|
/**
|
|
* Minimal valid NSKeyedArchiver whose object-table entries 10 and 11 both
|
|
* point at one Int64 record, beside an unrelated Int64 sibling (entry 12).
|
|
* The shared record is encoded once; only its offset is listed twice.
|
|
*/
|
|
const sharedEntryArchiveBytes = (): Buffer => {
|
|
const objects = [
|
|
Buffer.from([0xd3, 1, 3, 4, 2, 8, 5]), // root dict: three key/value pairs
|
|
ascii("$archiver"),
|
|
ascii("NSKeyedArchiver"),
|
|
ascii("$objects"),
|
|
ascii("$top"),
|
|
Buffer.from([0xd1, 6, 7]), // $top.root -> UID 1
|
|
ascii("root"),
|
|
Buffer.from([0x80, 1]), // UID 1
|
|
Buffer.from([0xa4, 9, 10, 11, 12]), // $objects refs
|
|
ascii("$null"),
|
|
integer64(9_007_199_254_740_993n),
|
|
integer64(1_152_921_504_606_846_979n),
|
|
];
|
|
const header = Buffer.from("bplist00", "ascii");
|
|
const offsets: number[] = [];
|
|
let position = header.length;
|
|
for (const object of objects) {
|
|
offsets.push(position);
|
|
position += object.length;
|
|
}
|
|
// Entries 10 and 11 share the first Int64 record; entry 12 is the sibling.
|
|
const [shared, sibling] = [offsets[10] ?? 0, offsets[11] ?? 0];
|
|
offsets.splice(11, 1, shared, sibling);
|
|
const trailer = Buffer.alloc(32);
|
|
trailer[6] = 1;
|
|
trailer[7] = 1;
|
|
trailer.writeBigUInt64BE(BigInt(offsets.length), 8);
|
|
trailer.writeBigUInt64BE(0n, 16);
|
|
trailer.writeBigUInt64BE(BigInt(position), 24);
|
|
return Buffer.concat([header, ...objects, Buffer.from(offsets), trailer]);
|
|
};
|
|
|
|
export const keyedArchiveSharedEntryFixture = async () => {
|
|
const root = await createTestTempDirectory("rea-keyed-shared-entry-");
|
|
const path = join(root, "archive.plist");
|
|
const bytes = sharedEntryArchiveBytes();
|
|
await writeFile(path, bytes);
|
|
return {
|
|
root,
|
|
path,
|
|
bytes,
|
|
digest: createHash("sha256").update(bytes).digest("hex"),
|
|
};
|
|
};
|
|
|
|
/** Minimal valid NSKeyedArchiver with a 16-byte integer and an Int64 control. */
|
|
const archiveBytes = (): Buffer => {
|
|
const objects = [
|
|
Buffer.from([0xd3, 1, 3, 4, 2, 8, 5]), // root dict: three key/value pairs
|
|
ascii("$archiver"),
|
|
ascii("NSKeyedArchiver"),
|
|
ascii("$objects"),
|
|
ascii("$top"),
|
|
Buffer.from([0xd1, 6, 7]), // $top.root -> UID 1
|
|
ascii("root"),
|
|
Buffer.from([0x80, 1]), // UID 1
|
|
Buffer.from([0xa3, 9, 10, 11]), // $objects refs
|
|
ascii("$null"),
|
|
integer128(18_446_744_073_709_551_615n),
|
|
integer64(9_223_372_036_854_775_807n),
|
|
];
|
|
const header = Buffer.from("bplist00", "ascii");
|
|
const offsets: number[] = [];
|
|
let position = header.length;
|
|
for (const object of objects) {
|
|
offsets.push(position);
|
|
position += object.length;
|
|
}
|
|
const offsetTable = Buffer.from(offsets);
|
|
const trailer = Buffer.alloc(32);
|
|
trailer[6] = 1;
|
|
trailer[7] = 1;
|
|
trailer.writeBigUInt64BE(BigInt(objects.length), 8);
|
|
trailer.writeBigUInt64BE(0n, 16);
|
|
trailer.writeBigUInt64BE(BigInt(position), 24);
|
|
return Buffer.concat([header, ...objects, offsetTable, trailer]);
|
|
};
|
|
|
|
export const keyedArchiveWideIntegerFixture = async () => {
|
|
const root = await createTestTempDirectory("rea-keyed-wide-integers-");
|
|
const path = join(root, "archive.bin");
|
|
const bytes = archiveBytes();
|
|
await writeFile(path, bytes);
|
|
return {
|
|
root,
|
|
path,
|
|
bytes,
|
|
digest: createHash("sha256").update(bytes).digest("hex"),
|
|
};
|
|
};
|