import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { buildBinary } from "plist";
import { describe, expect, it } from "vitest";
import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
import { preflightPropertyListDecode } from "./InterfaceBuilderDecodeBudget.js";
import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
const example = "";
it.each([
[
"XML",
Buffer.from(
"9007199254740993",
),
],
["binary", Buffer.from(buildBinary([9007199254740992]))],
] as const)(
"reserves %s number metadata and value projection before decoding",
(_format, bytes) => {
const ordinary = preflightPropertyListDecode(
bytes,
1024 * 1024,
).estimatedBytes;
const projected = preflightPropertyListDecode(
bytes,
1024 * 1024,
undefined,
{ numberProjection: true },
).estimatedBytes;
if (_format === "binary")
expect(projected).toBeGreaterThanOrEqual(ordinary + bytes.length);
expect(() =>
preflightPropertyListDecode(bytes, ordinary, undefined, {
numberProjection: true,
}),
).toThrow(/budget/u);
expect(() =>
preflightPropertyListDecode(bytes, 1024 * 1024, undefined, {
numberProjection: true,
}),
).not.toThrow();
},
);
const archive = (content: string, comment = "") =>
`${comment}$archiverNSKeyedArchiver$objects$null$classCF$UID2title${content}$classnameNSButton$classesNSButtonNSObject$toprootCF$UID1`;
const inertDeclarations = [
{
kind: "CDATA text",
xml: archive(``),
title: example,
},
{
kind: "XML comment",
xml: archive("Build", ``),
title: "Build",
},
] as const;
describe("inert XML declaration examples in Interface Builder archives", () => {
it.each(inertDeclarations)(
"preflights $kind without treating it as entity expansion",
({ xml }) => {
expect(() => parseXmlPropertyList(xml)).not.toThrow();
expect(
preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024),
).toMatchObject({
binaryPrototypeKeyObserved: false,
});
},
);
it.each(inertDeclarations)(
"retains $kind and archive provenance through bundle decoding",
async ({ xml, title }) => {
const root = await createTestTempDirectory("rea-ib-inert-declaration-");
const bundle = join(root, "Example.app");
await mkdir(bundle);
const bytes = Buffer.from(xml);
await writeFile(join(bundle, "Panel.nib"), bytes);
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "a".repeat(64),
});
expect(result.documents).toContainEqual(
expect.objectContaining({
relative_path: "Panel.nib",
archive_sha256: createHash("sha256").update(bytes).digest("hex"),
}),
);
expect(result.graph.nodes.map(({ name }) => name)).toContain(title);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "complete",
}),
);
},
);
it("continues to refuse an actual internal entity declaration before decoding", () => {
const xml = archive("&harmless;").replace(
"]>
preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024),
).toThrow(
"XML plist internal entity expansion exceeds the aggregate Interface Builder decode budget",
);
});
it("does not mistake a bracket in a quoted external identifier for an internal subset", () => {
const xml = archive("Build").replace(
" parseXmlPropertyList(xml)).not.toThrow();
expect(() =>
preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024),
).not.toThrow();
});
it.each([
'',
"",
" ?>",
])("consumes quoted or opaque declaration text once: %s", (declaration) => {
const xml = archive("Build").replace(" parseXmlPropertyList(xml)).not.toThrow();
expect(
preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024),
).toMatchObject({ binaryPrototypeKeyObserved: false });
});
});