fix(apple): preserve XML plist UID reference identity

This commit is contained in:
morluto committed 2026-10-24 06:43:44 +08:00
1 parent 2886cc1efe
commit 91c28e2735
10 files changed
+199 -105

No files matched your search

+5 -3
View File
@@ -549,9 +549,11 @@ complete observation.
malformed/unresolved/nil references. Missing fields stay absent; unknown
classes are preserved. Select `root`, `offset` and `limit` explicitly.
Binary references retain their serialized type-8 UID identity; ordinary
dictionaries with `UID` or `CF$UID` keys remain authored data. XML has no UID
scalar, so its dictionary UID convention remains ambiguous with authored
dictionaries of the same shape. Object `kind` describes the decoded archive value: data, dates and numbers
dictionaries with `UID` or `CF$UID` keys remain authored data. XML uses
Foundation's single numeric `CF$UID` dictionary convention for references;
a numeric `UID` dictionary remains authored data. Reference identity follows
the parsed format, rather than the displayed key alone. Object `kind`
describes the decoded archive value: data, dates and numbers
remain `scalar` when their JSON value uses a `$plist_type` wrapper. An actual
dictionary with the same fields remains `dictionary`.
XML must be well-formed. Data literals use
@@ -837,3 +837,88 @@ it("distinguishes binary UID records from authored UID-shaped dictionaries", ()
{ 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 when prototype-key omission leaves a 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 } });
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).toContainEqual(
expect.stringMatching(
/\b1 dictionary entry\b.*\b__proto__\b.*\bomitted\b/u,
),
);
});
+3 -3
View File
@@ -1,4 +1,4 @@
import { markBinaryPlistDictionary } from "../../domain/apple/plistUid.js";
import { markPlistDictionary } from "../../domain/apple/plistUid.js";
import {
createPlistNumberCollector,
type PlistNumberLiterals,
@@ -408,7 +408,7 @@ export const retainBinaryPlistUidIdentity = (
!(item instanceof Date) &&
!(item instanceof Uint8Array)
) {
markBinaryPlistDictionary(item);
markPlistDictionary(item);
for (const child of Object.values(item)) pending.push(child);
}
}
@@ -428,7 +428,7 @@ export const retainBinaryPlistUidIdentity = (
}
if (item !== null && typeof item === "object") {
const uid = Object.getOwnPropertyDescriptor(item, "UID")?.value;
if (typeof uid === "number") markBinaryPlistDictionary(item, uid);
if (typeof uid === "number") markPlistDictionary(item, uid);
}
}
return value;
+69 -71
View File
@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { buildBinary, parseBinary } from "plist";
import {
buildInterfaceBuilderAnalysis,
@@ -158,20 +157,19 @@ describe("compiled Interface Builder graph projection", () => {
});
it("reads recognized objects from a compiled NSKeyedArchiver object table", () => {
const bytes = buildBinary({
const parsed = parseInterfaceBuilderRecords({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
"$null",
{ $class: { UID: 2 }, title: "Build", identifier: "build-button" },
{ $class: { CF$UID: 2 }, title: "Build", identifier: "build-button" },
{
$classname: "UIButton",
$classes: ["UIButton", "UIControl", "UIView", "NSObject"],
},
],
$top: { root: { UID: 1 } },
$top: { root: { CF$UID: 1 } },
});
const parsed = parseInterfaceBuilderRecords(parseBinary(bytes));
expect(parsed.objects).toMatchObject([
{
id: "1",
@@ -190,12 +188,12 @@ describe("keyed archive objects with unresolved classes", () => {
$version: 100000,
$objects: [
"$null",
{ $class: { UID: 99 }, label: "Unresolved object" },
{ $class: { CF$UID: 99 }, label: "Unresolved object" },
{ $classname: "UIView", $classes: ["UIView", "NSObject"] },
{ $class: { UID: 2 }, "NS.string": "ok" },
{ $class: { CF$UID: 2 }, "NS.string": "ok" },
"plain scalar",
],
$top: { root: { UID: 3 } },
$top: { root: { CF$UID: 3 } },
});
// The class descriptor and the scalar are not archived objects.
expect(parsed.objects).toMatchObject([
@@ -204,7 +202,7 @@ describe("keyed archive objects with unresolved classes", () => {
kind: "other",
class_name: null,
name: "Unresolved object",
attributes: { label: "Unresolved object", $class: { UID: 99 } },
attributes: { label: "Unresolved object", $class: { CF$UID: 99 } },
},
{ id: "3", kind: "view", class_name: "UIView" },
]);
@@ -226,11 +224,11 @@ describe("keyed archive objects with unresolved classes", () => {
$version: 100000,
$objects: [
"$null",
{ $class: { UID: 99 }, label: "Unresolved object" },
{ $class: { CF$UID: 99 }, label: "Unresolved object" },
{ $classname: "UIView", $classes: ["UIView", "NSObject"] },
{ $class: { UID: 2 }, "NS.string": "ok" },
{ $class: { CF$UID: 2 }, "NS.string": "ok" },
],
$top: { root: { UID: 1 } },
$top: { root: { CF$UID: 1 } },
},
},
],
@@ -313,13 +311,13 @@ describe("keyed archive identity decoding", () => {
$version: 100000,
$objects: [
"$null",
{ archiveUID: 2, title: "First", $class: { UID: 4 } },
{ title: "Second", $class: { UID: 4 } },
{ archiveUID: 2, title: "First", $class: { CF$UID: 4 } },
{ title: "Second", $class: { CF$UID: 4 } },
{
NSSource: { UID: 2 },
NSDestination: { UID: 1 },
NSSource: { CF$UID: 2 },
NSDestination: { CF$UID: 1 },
NSLabel: "peer",
$class: { UID: 5 },
$class: { CF$UID: 5 },
},
{
$classname: "ArchiveProbeView",
@@ -330,7 +328,7 @@ describe("keyed archive identity decoding", () => {
$classes: ["NSNibOutletConnector", "NSObject"],
},
],
$top: { root: { UID: 1 } },
$top: { root: { CF$UID: 1 } },
});
expect(parsed.objects.map(({ id }) => id)).toEqual(["1", "2", "3"]);
@@ -352,16 +350,16 @@ describe("keyed archive identity decoding", () => {
$objects: [
"$null",
{
subviews: [{ UID: 2 }],
$class: { UID: 5 },
subviews: [{ CF$UID: 2 }],
$class: { CF$UID: 5 },
},
{ $class: { UID: 6 } },
{ $class: { UID: 7 } },
{ $class: { CF$UID: 6 } },
{ $class: { CF$UID: 7 } },
{
source: { UID: 3 },
destination: { UID: 2 },
source: { CF$UID: 3 },
destination: { CF$UID: 2 },
label: "runAction:",
$class: { UID: 8 },
$class: { CF$UID: 8 },
},
{ $classname: "UIView", $classes: ["UIView", "NSObject"] },
{ $classname: "UIButton", $classes: ["UIButton", "UIView"] },
@@ -373,9 +371,9 @@ describe("keyed archive identity decoding", () => {
$classname: "NSNibControlConnector",
$classes: ["NSNibControlConnector", "NSObject"],
},
[{ UID: 1 }],
[{ CF$UID: 1 }],
],
$top: { root: { UID: 9 } },
$top: { root: { CF$UID: 9 } },
});
expect(parsed.objects.map(({ id }) => id)).toContain("2");
@@ -694,24 +692,24 @@ describe("keyed archive object references", () => {
// inlining references would expand the table into an exponential tree.
const viewCount = 40;
const views = Array.from({ length: viewCount }, (_, index) => ({
$class: { UID: 1 },
NSWindow: { UID: 2 },
NSSuperview: { UID: index === 0 ? 2 : 3 + index - 1 },
NSNextKeyView: { UID: 3 + ((index + 1) % viewCount) },
NSSubviews: index + 1 < viewCount ? [{ UID: 3 + index + 1 }] : [],
NSTitle: { UID: 3 + viewCount },
$class: { CF$UID: 1 },
NSWindow: { CF$UID: 2 },
NSSuperview: { CF$UID: index === 0 ? 2 : 3 + index - 1 },
NSNextKeyView: { CF$UID: 3 + ((index + 1) % viewCount) },
NSSubviews: index + 1 < viewCount ? [{ CF$UID: 3 + index + 1 }] : [],
NSTitle: { CF$UID: 3 + viewCount },
}));
const parsed = parseInterfaceBuilderRecords({
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{ $classname: "NSView", $classes: ["NSView", "NSObject"] },
{ $class: { UID: 3 + viewCount + 1 }, NSContentView: { UID: 3 } },
{ $class: { CF$UID: 3 + viewCount + 1 }, NSContentView: { CF$UID: 3 } },
...views,
"Title",
{ $classname: "NSWindow", $classes: ["NSWindow", "NSObject"] },
],
$top: { root: { UID: 2 } },
$top: { root: { CF$UID: 2 } },
});
expect(parsed.objects).toHaveLength(viewCount + 1);
@@ -779,19 +777,19 @@ describe("AppKit keyed archive connectors", () => {
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{ $class: { UID: 6 } },
{ $class: { UID: 7 }, identifier: "unrelated-identifier" },
{ $class: { CF$UID: 6 } },
{ $class: { CF$UID: 7 }, identifier: "unrelated-identifier" },
{
$class: { UID: 8 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
NSLabel: { UID: 5 },
$class: { CF$UID: 8 },
NSSource: { CF$UID: 1 },
NSDestination: { CF$UID: 2 },
NSLabel: { CF$UID: 5 },
},
{
$class: { UID: 9 },
NSSource: { UID: 2 },
NSDestination: { UID: 1 },
NSLabel: { UID: 10 },
$class: { CF$UID: 9 },
NSSource: { CF$UID: 2 },
NSDestination: { CF$UID: 1 },
NSLabel: { CF$UID: 10 },
},
"o_button",
{
@@ -835,24 +833,24 @@ describe("unsupported keyed archive connections", () => {
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{ $class: { UID: 6 } },
{ $class: { UID: 6 } },
{ $class: { CF$UID: 6 } },
{ $class: { CF$UID: 6 } },
{
$class: { UID: 7 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
$class: { CF$UID: 7 },
NSSource: { CF$UID: 1 },
NSDestination: { CF$UID: 2 },
NSLabel: "outlet",
},
{
$class: { UID: 8 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
$class: { CF$UID: 8 },
NSSource: { CF$UID: 1 },
NSDestination: { CF$UID: 2 },
NSLabel: "performAction:",
},
{
$class: { UID: 9 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
$class: { CF$UID: 9 },
NSSource: { CF$UID: 1 },
NSDestination: { CF$UID: 2 },
NSLabel: "value",
},
{ $classname: "NSView", $classes: ["NSView", "NSObject"] },
@@ -921,9 +919,9 @@ describe("keyed archive class identity", () => {
$objects: [
"$null",
{
$class: { UID: 2 },
NSSource: { UID: 1 },
NSDestination: { UID: 1 },
$class: { CF$UID: 2 },
NSSource: { CF$UID: 1 },
NSDestination: { CF$UID: 1 },
},
{
$classname: "TelemetryConnectorState",
@@ -947,9 +945,9 @@ describe("keyed archive class identity", () => {
$objects: [
"$null",
{
$class: { UID: 3 },
NSSource: { UID: 1 },
NSDestination: { UID: 1 },
$class: { CF$UID: 3 },
NSSource: { CF$UID: 1 },
NSDestination: { CF$UID: 1 },
},
{ $classname: "NSNibConnector", $classes: ["NSNibConnector"] },
{
@@ -970,9 +968,9 @@ describe("keyed archive class identity", () => {
$objects: [
"$null",
{
$class: { UID: 99 },
NSSource: { UID: 1 },
NSDestination: { UID: 1 },
$class: { CF$UID: 99 },
NSSource: { CF$UID: 1 },
NSDestination: { CF$UID: 1 },
},
],
$top: {},
@@ -982,7 +980,7 @@ describe("keyed archive class identity", () => {
expect.objectContaining({
class_name: null,
id: "1",
attributes: expect.objectContaining({ $class: { UID: 99 } }),
attributes: expect.objectContaining({ $class: { CF$UID: 99 } }),
}),
);
expect(parsed.connections).toEqual([]);
@@ -1004,12 +1002,12 @@ describe("AppKit control connectors", () => {
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{ $class: { UID: 4 } },
{ $class: { UID: 5 } },
{ $class: { CF$UID: 4 } },
{ $class: { CF$UID: 5 } },
{
$class: { UID: 6 },
NSSource: { UID: 2 },
NSDestination: { UID: 1 },
$class: { CF$UID: 6 },
NSSource: { CF$UID: 2 },
NSDestination: { CF$UID: 1 },
NSLabel: "buttonClicked:",
},
{ $classname: "BuildDelegate", $classes: ["NSObject"] },
@@ -6,7 +6,7 @@ import {
} from "./interfaceBuilderGraph.js";
import { parseInterfaceBuilderRecords } from "./interfaceBuilderKeyedArchive.js";
const uid = (value: number) => ({ UID: value });
const uid = (value: number) => ({ CF$UID: value });
const archive = (
fields: Record<string, unknown> = {},
@@ -151,8 +151,8 @@ it.each([
{ id: "2" },
{ archiveUID: 2 },
{ archiveUID: 2, className: "NSView" },
{ UID: 2, archiveUID: 2 },
{ UID: "2" },
{ CF$UID: 2, archiveUID: 2 },
{ CF$UID: "2" },
uid(-1),
uid(99),
uid(0),
+3 -3
View File
@@ -49,7 +49,7 @@ describe("projectKeyedArchive root reference projection", () => {
$objects: ["$null", "target"],
$top: {
UID: { CF$UID: 1 },
CF$UID: { UID: 1 },
CF$UID: { CF$UID: 1 },
},
},
{ offset: 0, limit: 2 },
@@ -60,7 +60,7 @@ describe("projectKeyedArchive root reference projection", () => {
source: null,
path: ["CF$UID"],
target: 1,
raw: { UID: 1 },
raw: { CF$UID: 1 },
status: "resolved",
},
{
@@ -106,7 +106,7 @@ it("keeps generated unknown scalar wrappers scalar and lookalike dictionaries or
reason: "unsupported-markup",
},
],
$top: { root: { UID: 1 } },
$top: { root: { CF$UID: 1 } },
},
{ offset: 0, limit: 3 },
);
+3 -6
View File
@@ -1,3 +1,4 @@
import { plistUidValue } from "./plistUid.js";
import type { JsonValue } from "../jsonValue.js";
/** Source element types needed to distinguish large integers from integral reals. */
@@ -75,12 +76,8 @@ export const createPlistNumberProjection = (literals: PlistNumberLiterals) => {
if (typeof item === "number") return mapNumber(item);
if (Array.isArray(item)) return item.map(map);
if (item !== null && typeof item === "object") {
const keys = Object.keys(item);
const marker = item[keys[0] ?? ""];
const reference =
keys.length === 1 &&
(keys[0] === "UID" || keys[0] === "CF$UID") &&
typeof marker === "number";
const marker = plistUidValue(item);
const reference = marker !== undefined;
if (reference) {
// Keep malformed numeric reference markers recognizable by the graph reader.
if (
+11 -14
View File
@@ -1,32 +1,29 @@
/** Serialized binary UID identity is separate from an ordinary dictionary's keys. */
const binaryUidValues = new WeakMap<object, number | undefined>();
/** Serialized plist UID identity is separate from an ordinary dictionary's keys. */
const plistUidValues = new WeakMap<object, number | undefined>();
/** The binary decoder marks every dictionary; only type-8 records carry a UID. */
export const markBinaryPlistDictionary = (
value: object,
uid?: number,
): void => {
binaryUidValues.set(value, uid);
/** Decoders mark every dictionary with its format-defined reference identity. */
export const markPlistDictionary = (value: object, uid?: number): void => {
plistUidValues.set(value, uid);
};
/** Preserve source identity through the single JSON projection owner. */
/** Preserve source identity when a projection rebuilds dictionaries. */
export const copyPlistUidIdentity = (
source: object,
projected: object,
): void => {
if (binaryUidValues.has(source))
binaryUidValues.set(projected, binaryUidValues.get(source));
if (plistUidValues.has(source))
plistUidValues.set(projected, plistUidValues.get(source));
};
/** XML/untyped decoded archives use Foundation's dictionary UID convention. */
/** Source identity takes precedence over Foundation's XML CF$UID convention. */
export const plistUidValue = (value: unknown): number | undefined => {
if (value === null || typeof value !== "object" || Array.isArray(value))
return undefined;
if (binaryUidValues.has(value)) return binaryUidValues.get(value);
if (plistUidValues.has(value)) return plistUidValues.get(value);
const fields = Object.keys(value);
if (fields.length !== 1) return undefined;
const key = fields[0];
if (key !== "UID" && key !== "CF$UID") return undefined;
if (key !== "CF$UID") return undefined;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
return typeof descriptor?.value === "number" ? descriptor.value : undefined;
};
+4 -1
View File
@@ -1,3 +1,4 @@
import { copyPlistUidIdentity } from "./apple/plistUid.js";
import { parseOpenStep } from "plist";
import { decodeXmlPropertyList } from "./propertyListXml.js";
@@ -47,13 +48,15 @@ export const omitPrototypeKeys = (
item instanceof UnknownPlistScalar
)
return item;
return Object.fromEntries(
const projected = Object.fromEntries(
Object.entries(item).flatMap(([name, entry]) => {
if (name !== PROTOTYPE_KEY) return [[name, strip(entry)]];
omitted += 1;
return [];
}),
);
copyPlistUidIdentity(item, projected);
return projected;
};
const stripped = strip(value);
return { value: stripped, omittedPrototypeKeys: omitted };
+13 -1
View File
@@ -1,3 +1,4 @@
import { markPlistDictionary } from "./apple/plistUid.js";
import type { Node } from "@xmldom/xmldom";
import { readXmlPlistSource } from "./propertyListXmlSource.js";
@@ -88,7 +89,18 @@ export const decodeXmlPropertyList = (text: string) => {
if (key !== undefined)
throw new TypeError("Missing value for key while parsing <dict/>");
// Define own properties safely, including __proto__; the caller owns omission accounting.
return Object.fromEntries(entries);
const value = Object.fromEntries(entries);
// XML archives use Foundation's CF$UID convention; a UID key is data.
const keys = Object.keys(value);
markPlistDictionary(
value,
keys.length === 1 &&
keys[0] === "CF$UID" &&
typeof value["CF$UID"] === "number"
? value["CF$UID"]
: undefined,
);
return value;
}
case "key": {
// The pinned decoder also accepted key elements as scalars. Keep their