mirror of
https://github.com/morluto/rea
synced 2026-10-26 22:28:27 -04:00
8d3cfef753
Reuse the bounded binary preflight to observe dictionary keys before the pinned decoder drops them. Keyed archive results disclose the omission and Interface Builder marks archive decoding partial. XML keeps its existing exact counts; binary reporting deliberately leaves the count unknown because overwritten subtrees and decoder projection can change visibility. Remove the superseded size-only preflight contract. Cover ASCII, UTF-16 and extended lengths, shared references, nested and shadowed dictionaries, and the value-only negative control. Synthetic plist and filesystem fixtures; no plutil or ibtool verification. Fixes #1594. Supersedes #1593.
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { build, buildBinary } from "plist";
|
|
import { decodeKeyedArchiveBytes } from "./KeyedArchiveReader.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 } },
|
|
};
|
|
describe("inert keyed archive decoding", () => {
|
|
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("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>",
|
|
);
|
|
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.",
|
|
);
|
|
});
|
|
it("reports a binary archive dictionary keyed __proto__ as omitted", () => {
|
|
const placeholder = "proto_key";
|
|
const encoded = Buffer.from(
|
|
buildBinary({
|
|
...archive,
|
|
[placeholder]: "hidden",
|
|
$objects: ["$null", "value"],
|
|
}),
|
|
);
|
|
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.limitations).toContain(
|
|
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
|
);
|
|
});
|
|
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).not.toContain(
|
|
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
|
);
|
|
});
|
|
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("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"],
|
|
]);
|
|
});
|
|
|
|
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.limitations).toContain(
|
|
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
|
);
|
|
});
|
|
|
|
it("reports binary omission presence without claiming a count", () => {
|
|
const placeholder = "proto_key";
|
|
const nested = Buffer.from(
|
|
buildBinary({
|
|
...archive,
|
|
[placeholder]: { [placeholder]: "hidden", visible: true },
|
|
$objects: ["$null", "value"],
|
|
}),
|
|
);
|
|
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.limitations).toContain(
|
|
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
|
);
|
|
|
|
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.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.",
|
|
);
|
|
});
|
|
|
|
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.limitations).toContain(
|
|
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
|
);
|
|
});
|
|
|
|
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.limitations).toContain(
|
|
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
|
);
|
|
});
|
|
});
|