Files
reverse-engineering-anything/src/artifacts/apple/KeyedArchiveReader.test.ts
T
morluto 5044e20171 test: focus diagnostics and evidence checks on behavior (#9303)
* test: focus configuration diagnostics on their owners

Preserve shape errors, selected paths, recovery guidance, and unchanged-file behavior across parser, setup, and uninstall boundaries.

* test: focus artifact and evidence checks on behavior

Retain format, identity, malformed-input, and cleanup coverage while removing fixed prose and closing the admitted test source.

* refactor(debugger): use configured session admission limits

* test: focus evidence and diagnostics on behavior

* fix(test): restore verifier export and formatting

* test(config): verify debugger limit ranges

* test(rizin): keep fixture executable authoritative

* test(rizin): isolate debugger fixture environment
2026-11-08 16:39:20 +00:00

1030 lines
34 KiB
TypeScript

import { binaryKeyedArchiveUidShapes } from "../../../tests/fixtures/keyedArchiveUidShapes.js";
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { build, buildBinary, type PlistValue } from "plist";
import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js";
import {
decodeKeyedArchiveBytes,
inspectBundleKeyedArchive,
} from "./KeyedArchiveReader.js";
import { PlistNestingLimitError } from "../../domain/apple/plistValue.js";
import { deeplyNestedXmlPlist } from "../../../tests/fixtures/keyedArchiveXmlNesting.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { embedSubmillisecondDateInWideIntegerPayload } from "../../../tests/fixtures/binaryPlistScalarAlias.js";
import {
readBinaryPlistObjectOffset,
readBinaryPlistObjectTableLayout,
writeBinaryPlistObjectOffset,
} from "../../../tests/fixtures/binaryPlistObjectTable.js";
const archive = {
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
"$null",
{
$class: { UID: 4 },
child: { UID: 2 },
shared: { UID: 2 },
self: { UID: 1 },
conditional: { UID: 0 },
broken: { UID: 90 },
},
{ "NS.objects": [{ UID: 1 }, { UID: 3 }] },
"value",
{ $classname: "UnknownModel", $classes: ["UnknownModel", "NSObject"] },
],
$top: { root: { UID: 1 }, other: { UID: 2 } },
};
const binaryArchiveWithDate = (): Buffer =>
Buffer.from(
buildBinary({
...archive,
$objects: ["$null", new Date("2001-01-01T00:00:00.100Z")],
$top: { root: { UID: 1 } },
}),
);
// Locate the known binary date independently of the scanner under test.
const fixtureDateOffset = (bytes: Buffer, seconds: number): number => {
const encoded = Buffer.alloc(9);
encoded[0] = 0x33;
encoded.writeDoubleBE(seconds, 1);
const offset = bytes.indexOf(encoded);
if (offset < 0 || bytes.indexOf(encoded, offset + 1) !== -1)
throw new Error("binary archive fixture must contain exactly one date");
return offset;
};
const binaryArchiveWithOverlappingDate = (): Buffer => {
const bytes = Buffer.from(
buildBinary({
...archive,
$objects: [
"$null",
new Date("2001-01-01T00:00:00.000Z"),
Buffer.alloc(0),
],
$top: { root: { UID: 1 } },
}),
);
const dateOffset = fixtureDateOffset(bytes, 0);
bytes.writeDoubleBE(0.0005, dateOffset + 1);
const objectTable = readBinaryPlistObjectTableLayout(bytes);
for (let objectId = 0; objectId < objectTable.objectCount; objectId += 1) {
const offset = readBinaryPlistObjectOffset(bytes, objectTable, objectId);
if (offset !== dateOffset && bytes[offset] === 0x40) {
writeBinaryPlistObjectOffset(
bytes,
objectTable,
objectId,
dateOffset + 2,
);
return bytes;
}
}
throw new Error("binary archive fixture lost its empty data object");
};
const binaryArchiveDateCrossingOffsetTable = (): Buffer => {
const bytes = Buffer.from(binaryArchiveWithDate());
const dateOffset = fixtureDateOffset(bytes, 0.1);
const objectTable = readBinaryPlistObjectTableLayout(bytes);
let dateObjectId = -1;
for (let objectId = 0; objectId < objectTable.objectCount; objectId += 1) {
if (
readBinaryPlistObjectOffset(bytes, objectTable, objectId) === dateOffset
)
dateObjectId = objectId;
}
if (dateObjectId < 0)
throw new Error("binary archive fixture lost its date object-table entry");
const crossingOffset = objectTable.tableOffset - 1;
bytes[crossingOffset] = 0x33;
writeBinaryPlistObjectOffset(
bytes,
objectTable,
dateObjectId,
crossingOffset,
);
return bytes;
};
describe("keyed archive scalar projection", () => {
it("preserves the exact integer and unknown date through archive projection", () => {
const source = Buffer.from(
buildBinary({
...archive,
$objects: ["$null", Buffer.alloc(16), new Date(0)],
$top: { root: { UID: 1 } },
}),
);
const { bytes, integerDecimal } =
embedSubmillisecondDateInWideIntegerPayload(source);
const graph = decodeKeyedArchiveBytes(bytes, {
root: "root",
offset: 1,
limit: 2,
});
expect(graph.objects).toContainEqual(
expect.objectContaining({
id: 1,
value: { $plist_type: "integer", decimal: integerDecimal },
}),
);
expect(graph.objects).toContainEqual(
expect.objectContaining({
id: 2,
value: { $plist_type: "date", iso8601: null },
}),
);
});
it("rejects a reachable date whose payload enters the offset table", () => {
expect(() =>
decodeKeyedArchiveBytes(binaryArchiveDateCrossingOffsetTable(), {
root: "root",
offset: 0,
limit: 2,
}),
).toThrow(/date.*truncated/iu);
});
it("keeps overlapping date precision unknown without changing its sibling object", () => {
const graph = decodeKeyedArchiveBytes(binaryArchiveWithOverlappingDate(), {
root: "root",
offset: 0,
limit: 4,
});
expect(graph.objects).toContainEqual(
expect.objectContaining({
id: 1,
value: { $plist_type: "date", iso8601: null },
}),
);
expect(graph.objects).toContainEqual(
expect.objectContaining({
id: 2,
value: { $plist_type: "data", base64: "" },
}),
);
expect(graph.limitations).toContainEqual(
expect.stringMatching(
/1 date.*unknown timestamps.*sub.millisecond.*overlap/iu,
),
);
});
});
describe("inert keyed archive decoding", () => {
it.each(["bplist00", "NIBArchive"])(
"rejects high-bit mutations of %s as malformed bytes rather than another archive kind",
(signature) => {
for (let index = 0; index < signature.length; index += 1) {
const bytes = Buffer.from(signature);
bytes[index] = (bytes[index] ?? 0) | 0x80;
expect(() =>
decodeKeyedArchiveBytes(bytes, { offset: 0, limit: 1 }),
).toThrow(
expect.objectContaining({
name: "XmlPlistTextDecodeError",
reason: "invalid-bytes",
cause: expect.any(TypeError),
}),
);
}
},
);
it("preserves CF$UID, malformed references and original pagination identities", () => {
const bytes = Buffer.from(
build({
...archive,
$objects: [
"$null",
{ bad: { CF$UID: -1 }, valid: { CF$UID: 2 }, broken: { CF$UID: 90 } },
"value",
],
}),
);
const graph = decodeKeyedArchiveBytes(bytes, {
root: "root",
offset: 1,
limit: 1,
});
expect(graph.roots).toEqual({ root: { UID: 1 } });
expect(graph.objects.map(({ id }) => id)).toEqual([1]);
expect(graph.next_offset).toBe(2);
expect(graph.references).toContainEqual(
expect.objectContaining({ source: 1, target: 90, status: "unresolved" }),
);
expect(graph.references).toContainEqual(
expect.objectContaining({ source: 1, target: null, status: "malformed" }),
);
expect(graph.references).toContainEqual(
expect.objectContaining({ source: 1, target: 2, status: "resolved" }),
);
});
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",
{ ["__proto__"]: "x" },
]);
expect(
graph.limitations.some((value) => value.includes("keyed __proto__")),
).toBe(false);
});
it("preserves a binary object dictionary keyed __proto__", () => {
const placeholder = "proto_key";
const encoded = Buffer.from(
buildBinary({
...archive,
$objects: ["$null", { [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 graph = decodeKeyedArchiveBytes(encoded, { offset: 0, limit: 2 });
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(
Buffer.from(
buildBinary({
...archive,
note: "__proto__",
$objects: ["$null", "value"],
}),
),
{ offset: 0, limit: 2 },
);
expect(
graph.limitations.some((value) => value.includes("keyed __proto__")),
).toBe(false);
});
it("rejects missing roots, malformed plist, and non-keyed archives", () => {
expect(() =>
decodeKeyedArchiveBytes(Buffer.from("bplist00bad"), {
offset: 0,
limit: 1,
}),
).toThrow();
expect(() =>
decodeKeyedArchiveBytes(Buffer.from(build({ ...archive, $top: {} })), {
offset: 0,
limit: 1,
}),
).toThrow("roots");
expect(() =>
decodeKeyedArchiveBytes(Buffer.from(build({ plain: true })), {
offset: 0,
limit: 1,
}),
).toThrow("NSKeyedArchiver");
expect(() =>
decodeKeyedArchiveBytes(Buffer.from(buildBinary(archive)), {
root: "missing",
offset: 0,
limit: 1,
}),
).toThrow(
expect.objectContaining({
issues: [
expect.objectContaining({
path: ["root"],
message: expect.stringContaining("does not exist"),
expected: ["root", "other"],
}),
],
}),
);
});
});
/** Encode a bplist00 whose objects reference each other by index. */
const binaryPlist = (objects: readonly Buffer[]): Buffer => {
const header = Buffer.from("bplist00");
const offsets: number[] = [];
let position = header.length;
for (const object of objects) {
offsets.push(position);
position += object.length;
}
const table = Buffer.alloc(objects.length * 4);
offsets.forEach((offset, index) => table.writeUInt32BE(offset, index * 4));
const trailer = Buffer.alloc(32);
trailer[6] = 4;
trailer[7] = 2;
trailer.writeBigUInt64BE(BigInt(objects.length), 8);
trailer.writeBigUInt64BE(0n, 16);
trailer.writeBigUInt64BE(BigInt(position), 24);
return Buffer.concat([header, ...objects, table, trailer]);
};
const plistArray = (references: readonly number[]): Buffer => {
const bytes = Buffer.alloc(1 + references.length * 2);
bytes[0] = 0xa0 | references.length;
references.forEach((reference, index) =>
bytes.writeUInt16BE(reference, 1 + index * 2),
);
return bytes;
};
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([
Buffer.from([0xd3, 0, 1, 0, 3, 0, 4, 0, 2, 0, 8, 0, 5]),
plistString("$archiver"),
Buffer.concat([
Buffer.from([0x5f, 0x10, 15]),
Buffer.from("NSKeyedArchiver"),
]),
plistString("$objects"),
plistString("$top"),
Buffer.from([0xd1, 0, 6, 0, 7]),
plistString("root"),
Buffer.from([0x80, 1]),
...payload,
]);
describe("binary plist reference preflight", () => {
const selection = { offset: 0, limit: 10 };
it("rejects an array key that coerces to __proto__ before parsing", () => {
expect(() =>
decodeKeyedArchiveBytes(
keyedRoot([
plistArray([9]),
Buffer.from([0xd1, 0, 10, 0, 11]),
plistArray([12]),
Buffer.from([0xd1, 0, 13, 0, 14]),
plistString("__proto__"),
plistString("lost"),
plistString("value"),
]),
selection,
),
).toThrow("binary plist dictionary key is not a string");
});
it("rejects binary data dictionary keys", () => {
expect(() =>
decodeKeyedArchiveBytes(
keyedRoot([
plistArray([9]),
Buffer.from([0xd1, 0, 10, 0, 11]),
Buffer.from([0x41, 0xab]),
plistString("value"),
]),
selection,
),
).toThrow("binary plist dictionary key is not a string");
});
it("reports a reference cycle as malformed, not as an oversized archive", () => {
expect(() =>
decodeKeyedArchiveBytes(keyedRoot([plistArray([8])]), selection),
).toThrow(
new TypeError(
"binary plist object references form a cycle, which a property list cannot represent",
),
);
});
it("rejects exponential shared-container expansion before decoding", () => {
// Forty levels of [next, next] expand to 2^40 leaves in a few hundred bytes.
const levels = Array.from({ length: 40 }, (_, index) =>
plistArray([9 + index, 9 + index]),
);
expect(() =>
decodeKeyedArchiveBytes(
keyedRoot([...levels, plistString("x")]),
selection,
),
).toThrow(RangeError);
});
it("decodes shared leaf references", () => {
const decoded = decodeKeyedArchiveBytes(
keyedRoot([plistArray([9, 9, 9]), plistString("$null")]),
selection,
);
expect(decoded.objects.map(({ id, value }) => [id, value])).toEqual([
[0, "$null"],
[1, "$null"],
[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(
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.objects.map(({ value }) => value)).toEqual([
{ ["__proto__"]: "first" },
{ ["__proto__"]: "second" },
]);
expect(
graph.limitations.some((value) => value.includes("keyed __proto__")),
).toBe(false);
});
it("preserves nested keys while duplicate entries retain the last value", () => {
const placeholder = "proto_key";
const nested = Buffer.from(
buildBinary({
...archive,
$objects: [
"$null",
{ [placeholder]: { [placeholder]: "hidden", visible: true } },
],
}),
);
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.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]),
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.objects[0]?.value).toEqual({ shadow: "replacement" });
});
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.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", () => {
const graph = decodeKeyedArchiveBytes(
keyedRoot([
plistArray([9]),
Buffer.from([0xd1, 0, 10, 0, 11]),
plistExtendedUtf16String("__proto__"),
plistString("hidden"),
]),
selection,
);
expect(graph.objects[0]?.value).toEqual({ ["__proto__"]: "hidden" });
expect(
graph.limitations.some((value) => value.includes("keyed __proto__")),
).toBe(false);
});
});
/**
* Python `plistlib` binary NSKeyedArchiver with `$objects = ["$null", date]`
* and `$top.root` as UID 1. The date is marker `0x33` and float64 `0.1`
* seconds after the 2001 epoch (`2001-01-01T00:00:00.100Z`).
*/
const pythonBinaryDateControl = Buffer.from(
"62706c6973743030d4010203040506090c5924617263686976657258246f626a656374735424746f70582476657273696f6e5f100f4e534b657965644172636869766572a2070855246e756c6c333fb999999999999ad10a0b54726f6f74800112000186a008111b24293244474d56595e600000000000000101000000000000000d00000000000000000000000000000065",
"hex",
);
const canonicalDateMarker = Buffer.alloc(9);
canonicalDateMarker[0] = 0x33;
canonicalDateMarker.writeDoubleBE(0.1, 1);
/** Copy the control and mutate its single canonical date marker. */
const mutateCanonicalDate = (
mutate: (markerOffset: number, bytes: Buffer) => void,
): Buffer => {
const bytes = Buffer.from(pythonBinaryDateControl);
const offset = bytes.indexOf(canonicalDateMarker);
if (offset < 0 || bytes.indexOf(canonicalDateMarker, offset + 1) !== -1)
throw new Error("expected one canonical binary date in the control");
mutate(offset, bytes);
return bytes;
};
const dateScalar = (iso8601: string | null) => ({
id: 1,
kind: "scalar" as const,
status: "observed" as const,
value: { $plist_type: "date", iso8601 },
});
const oneUnknownDate = /\b1 date value.*could not represent.*timestamps/iu;
const twoUnknownDates = /\b2 date value.*could not represent.*timestamps/iu;
describe("binary keyed archive dates", () => {
const selection = { offset: 0, limit: 10 };
it("keeps the canonical millisecond control as an observed timestamp", async () => {
const bytes = Buffer.from(pythonBinaryDateControl);
const graph = decodeKeyedArchiveBytes(bytes, selection);
expect(graph.objects[1]).toMatchObject(
dateScalar("2001-01-01T00:00:00.100Z"),
);
expect(graph.limitations).not.toContainEqual(
expect.stringMatching(
/date value.*decoder could not represent.*timestamps/iu,
),
);
expect(Buffer.compare(bytes, pythonBinaryDateControl)).toBe(0);
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
"39f08db8341bd74220352dfd559485515cce1e6035e08f27a59ab8f856374a30",
);
const root = await createTestTempDirectory("rea-keyed-date-control-");
await writeFile(join(root, "archive.plist"), bytes);
const inspected = await inspectBundleKeyedArchive({
bundlePath: root,
targetSha256: createHash("sha256").update(bytes).digest("hex"),
parameters: { path: "archive.plist" },
});
expect(inspected.objects[1]).toMatchObject(
dateScalar("2001-01-01T00:00:00.100Z"),
);
expect(inspected.limitations).not.toContainEqual(
expect.stringMatching(
/date value.*decoder could not represent.*timestamps/iu,
),
);
expect(
Buffer.compare(await readFile(join(root, "archive.plist")), bytes),
).toBe(0);
});
});
describe("binary keyed archive invalid dates", () => {
const selection = { offset: 0, limit: 10 };
it.each(
[0x30, 0x31, 0x32, 0x34].map(
(marker) => [marker.toString(16), marker] as const,
),
)(
"refuses reachable binary date marker 0x%s instead of projecting a timestamp",
async (_hex, marker) => {
const bytes = mutateCanonicalDate((offset, copy) => {
copy[offset] = marker;
});
const original = Buffer.from(bytes);
let decodeFailure: unknown;
try {
decodeKeyedArchiveBytes(bytes, selection);
} catch (cause: unknown) {
decodeFailure = cause;
}
expect(decodeFailure).toBeInstanceOf(TypeError);
if (!(decodeFailure instanceof Error))
throw new Error("Expected invalid date marker to fail");
expect(decodeFailure.message).toContain(`0x${marker.toString(16)}`);
expect(decodeFailure.message).toMatch(/date marker.*invalid/iu);
expect(Buffer.compare(bytes, original)).toBe(0);
if (marker === 0x30)
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
"ac5696b866c312feb53c2e82edd8fea79dbeb572eb1b07e4291603faa41e8c0e",
);
const root = await createTestTempDirectory("rea-keyed-date-marker-");
const path = join(root, "archive.plist");
await writeFile(path, bytes);
await expect(
inspectBundleKeyedArchive({
bundlePath: root,
targetSha256: null,
parameters: { path: "archive.plist" },
}),
).rejects.toMatchObject({
name: "ArtifactReaderFailure",
reason: "format",
message: expect.stringContaining(decodeFailure.message),
});
expect(Buffer.compare(await readFile(path), bytes)).toBe(0);
},
);
it("keeps a sub-millisecond instant unknown without changing the canonical sibling", async () => {
const submillisecond = mutateCanonicalDate((offset, copy) => {
copy.writeDoubleBE(0.1005, offset + 1);
});
const original = Buffer.from(submillisecond);
const graph = decodeKeyedArchiveBytes(submillisecond, selection);
expect(graph.objects[1]).toMatchObject(dateScalar(null));
expect(JSON.stringify(graph.objects[1]?.value)).not.toContain(
"2001-01-01T00:00:00.100Z",
);
expect(graph.limitations).toContainEqual(
expect.stringMatching(oneUnknownDate),
);
expect(graph.limitations).not.toContainEqual(
expect.stringMatching(/number metadata.*could not.*observed/iu),
);
expect(Buffer.compare(submillisecond, original)).toBe(0);
expect(createHash("sha256").update(submillisecond).digest("hex")).toBe(
"ab69ba6e8034c6f60b828ee323a5a3e81bae3e7657477e0ebfbfdb93d53ed2ce",
);
const root = await createTestTempDirectory("rea-keyed-date-subms-");
await writeFile(join(root, "archive.plist"), submillisecond);
const inspected = await inspectBundleKeyedArchive({
bundlePath: root,
targetSha256: createHash("sha256").update(submillisecond).digest("hex"),
parameters: { path: "archive.plist" },
});
expect(inspected.objects[1]).toMatchObject(dateScalar(null));
expect(inspected.limitations).toContainEqual(
expect.stringMatching(oneUnknownDate),
);
expect(
Buffer.compare(
await readFile(join(root, "archive.plist")),
submillisecond,
),
).toBe(0);
const mixed = Buffer.from(
buildBinary({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
"$null",
new Date("2001-01-01T00:00:00.100Z"),
new Date("2001-01-01T00:00:00.100Z"),
new Date("2001-01-01T00:00:00.100Z"),
],
$top: { root: { UID: 1 } },
}),
);
const pattern = Buffer.from(canonicalDateMarker);
const offsets: number[] = [];
for (
let offset = mixed.indexOf(pattern);
offset !== -1;
offset = mixed.indexOf(pattern, offset + pattern.length)
)
offsets.push(offset);
expect(offsets).toHaveLength(3);
// 0.1004 and 0.1005 both round to the same millisecond as the control.
mixed.writeDoubleBE(0.1004, (offsets[1] ?? 0) + 1);
mixed.writeDoubleBE(0.1005, (offsets[2] ?? 0) + 1);
const mixedOriginal = Buffer.from(mixed);
const mixedGraph = decodeKeyedArchiveBytes(mixed, selection);
expect(mixedGraph.objects.slice(1).map(({ value }) => value)).toEqual([
{ $plist_type: "date", iso8601: "2001-01-01T00:00:00.100Z" },
{ $plist_type: "date", iso8601: null },
{ $plist_type: "date", iso8601: null },
]);
expect(mixedGraph.limitations).toContainEqual(
expect.stringMatching(twoUnknownDates),
);
expect(Buffer.compare(mixed, mixedOriginal)).toBe(0);
});
});
describe("binary keyed archive post-table objects", () => {
it("preserves exact integer decimals when an archive string follows the offset table", () => {
const original = Buffer.from(
buildBinary({
$archiver: "NSKeyedArchiver",
$version: 100000,
$top: { root: { UID: 2 } },
$objects: [
"$null",
"hello",
9007199254740993n as unknown as PlistValue,
],
}),
);
const intMarkerOffset = original.indexOf(0x13);
expect(intMarkerOffset).toBeGreaterThan(0);
// Overwrite the 8-byte integer payload with exact 9007199254740993n
original.writeBigInt64BE(9007199254740993n, intMarkerOffset + 1);
const stringBytes = Buffer.from("\x55hello", "latin1");
const stringOffset = original.indexOf(stringBytes);
expect(stringOffset).toBeGreaterThan(0);
const trailerOffset = original.length - 32;
const tableOffset = Number(original.readBigUInt64BE(trailerOffset + 24));
const count = Number(original.readBigUInt64BE(trailerOffset + 8));
const width = original[trailerOffset + 6] ?? 1;
const post = Buffer.concat([
original.subarray(0, trailerOffset),
stringBytes,
original.subarray(trailerOffset),
]);
const newTrailerOffset = post.length - 32;
for (let i = 0; i < count; i += 1) {
const offset = post.readUIntBE(tableOffset + i * width, width);
if (offset === stringOffset) {
post.writeUIntBE(trailerOffset, tableOffset + i * width, width);
}
}
post.writeBigUInt64BE(BigInt(tableOffset), newTrailerOffset + 24);
const graph = decodeKeyedArchiveBytes(post, { offset: 0, limit: 10 });
expect(graph.objects[2]).toMatchObject({
id: 2,
status: "observed",
value: { $plist_type: "integer", decimal: "9007199254740993" },
});
expect(
graph.limitations.some((lim) =>
lim.includes("could not be completely observed"),
),
).toBe(false);
});
});
describe("binary plist date object region", () => {
it("refuses a binary date that extends past the object region", () => {
// Strict bounds end objects at the offset table. Four payload bytes
// remain before that table, short of the required eight.
const bytes = Buffer.alloc(8 + 5 + 1 + 32);
bytes.write("bplist00");
bytes[8] = 0x33;
const offsetTable = 13;
bytes[offsetTable] = 8;
const trailer = bytes.length - 32;
bytes[trailer + 6] = 1;
bytes[trailer + 7] = 1;
bytes.writeBigUInt64BE(1n, trailer + 8);
bytes.writeBigUInt64BE(0n, trailer + 16);
bytes.writeBigUInt64BE(BigInt(offsetTable), trailer + 24);
expect(() =>
estimateBinaryPlistExpansion(bytes, 1024 * 1024, { bounds: "strict" }),
).toThrow(/date.*truncated/iu);
// Decoder bounds extend other objects to the end of the file. The date
// payload still has to end at the offset table. The keyed-archive reader
// uses those bounds and must refuse the same payload.
expect(() =>
estimateBinaryPlistExpansion(bytes, 1024 * 1024, { bounds: "decoder" }),
).toThrow(/date.*truncated/iu);
expect(() =>
decodeKeyedArchiveBytes(bytes, { offset: 0, limit: 1 }),
).toThrow(/date.*truncated/iu);
});
it("refuses a decoder-bounds date whose marker sits past the offset table", () => {
// The one-byte offset table at byte 8 names an object at byte 9. That
// 0x33 marker and its eight payload bytes fit in the file, which decoder
// bounds would otherwise accept, but they are outside the object region.
const bytes = Buffer.alloc(64);
bytes.write("bplist00");
bytes[8] = 9;
bytes[9] = 0x33;
const trailer = bytes.length - 32;
bytes[trailer + 6] = 1;
bytes[trailer + 7] = 1;
bytes.writeBigUInt64BE(1n, trailer + 8);
bytes.writeBigUInt64BE(0n, trailer + 16);
bytes.writeBigUInt64BE(8n, trailer + 24);
expect(() =>
estimateBinaryPlistExpansion(bytes, 1024 * 1024, { bounds: "decoder" }),
).toThrow(/date.*truncated/iu);
expect(() =>
decodeKeyedArchiveBytes(bytes, { offset: 0, limit: 1 }),
).toThrow(/date.*truncated/iu);
});
});
describe("XML plist nesting preflight", () => {
const selection = { offset: 0, limit: 1 };
/** A valid XML NSKeyedArchiver envelope whose object 1 nests `depth` dictionaries. */
const deeplyNestedArchive = (depth: number) => {
let payload: PlistValue = "leaf";
for (let level = 0; level < depth; level += 1) payload = { k: payload };
return Buffer.from(
build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", payload],
$top: { root: { UID: 1 } },
}),
);
};
/** Follow the single-member chain of a decoded object to its deepest value. */
const deepestValue = (value: unknown): unknown => {
let cursor = value;
while (
cursor !== null &&
typeof cursor === "object" &&
!Array.isArray(cursor) &&
"k" in cursor
) {
cursor = (cursor as Record<string, unknown>).k;
}
return cursor;
};
it.each([129, 4000])(
"rejects %i-level XML nesting as the documented resource limit",
(depth) => {
// Recursive decoding exceeds the call stack before the projection limit
// can report it, so the decoder must reject the nesting up front with
// the same bounded-resource error an in-reach document would produce.
expect(() =>
decodeKeyedArchiveBytes(
Buffer.from(deeplyNestedXmlPlist(depth)),
selection,
),
).toThrow(new PlistNestingLimitError(128));
},
);
it("decodes a valid XML keyed archive exactly at the nesting limit", () => {
const graph = decodeKeyedArchiveBytes(deeplyNestedArchive(126), {
root: "root",
offset: 1,
limit: 1,
});
expect(graph.objects).toHaveLength(1);
expect(deepestValue(graph.objects[0]?.value)).toBe("leaf");
});
it("rejects one nesting level past the limit before decoding", () => {
expect(() =>
decodeKeyedArchiveBytes(deeplyNestedArchive(127), selection),
).toThrow(new PlistNestingLimitError(128));
});
});
it("distinguishes binary UID records from authored UID-shaped dictionaries", () => {
const graph = decodeKeyedArchiveBytes(binaryKeyedArchiveUidShapes(), {
offset: 0,
limit: 10,
});
expect(graph.objects[1]?.value).toMatchObject({
ordinary: { UID: 2 },
xmlConvention: { CF$UID: 2 },
actual: { UID: 2 },
});
expect(
graph.references
.filter(({ source }) => source === 1)
.map(({ path, target }) => ({ path, target })),
).toEqual([
{ path: ["$class"], target: 3 },
{ path: ["actual"], target: 2 },
{ path: ["shared", 0], target: 2 },
{ path: ["shared", 1], target: 2 },
]);
});
it("keeps XML authored UID dictionaries as data alongside CF$UID references", () => {
const graph = decodeKeyedArchiveBytes(
Buffer.from(
build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$top: { root: { CF$UID: 1 } },
$objects: [
"$null",
{ $class: { CF$UID: 2 }, authoredRecord: { UID: 3 } },
{ $classname: "T", $classes: ["T", "NSObject"] },
"sharedString",
],
}),
),
{ offset: 0, limit: 10 },
);
expect(graph.objects[1]?.value).toMatchObject({ authoredRecord: { UID: 3 } });
expect(graph.total_references).toBe(2);
expect(
graph.references.map(({ source, path, target, status }) => ({
source,
path,
target,
status,
})),
).toEqual([
{ source: null, path: ["root"], target: 1, status: "resolved" },
{ source: 1, path: ["$class"], target: 2, status: "resolved" },
]);
});
it("preserves exact integers in XML authored UID dictionaries", () => {
const xml = build({
$archiver: "NSKeyedArchiver",
$objects: ["$null", { UID: 9007199254740992 }],
$top: { root: { CF$UID: 1 } },
}).replace("9007199254740992", "9007199254740993");
const graph = decodeKeyedArchiveBytes(Buffer.from(xml), {
offset: 0,
limit: 10,
});
expect(graph.objects[1]?.value).toEqual({
UID: { $plist_type: "integer", decimal: "9007199254740993" },
});
expect(graph.total_references).toBe(1);
});
it("preserves XML dictionary identity beside an authored CF$UID field", () => {
const xml = build({
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{ authoredRecord: { CF$UID: 2, prototypeKey: "authored" } },
"target",
],
$top: { root: { CF$UID: 1 }, genuine: { CF$UID: 2 } },
}).replace("<key>prototypeKey</key>", "<key>__proto__</key>");
const graph = decodeKeyedArchiveBytes(Buffer.from(xml), {
offset: 0,
limit: 10,
});
expect(graph.objects[1]?.value).toEqual({
authoredRecord: { CF$UID: 2, ["__proto__"]: "authored" },
});
expect(graph.total_references).toBe(2);
expect(graph.references).toEqual([
expect.objectContaining({
source: null,
path: ["genuine"],
target: 2,
status: "resolved",
}),
expect.objectContaining({
source: null,
path: ["root"],
target: 1,
status: "resolved",
}),
]);
expect(
graph.limitations.some((value) => value.includes("keyed __proto__")),
).toBe(false);
});