diff --git a/docs/native-investigation.md b/docs/native-investigation.md
index 074c2c82da..7817302b19 100644
--- a/docs/native-investigation.md
+++ b/docs/native-investigation.md
@@ -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
diff --git a/src/artifacts/apple/KeyedArchiveReader.test.ts b/src/artifacts/apple/KeyedArchiveReader.test.ts
index 864307ce39..c2e6019864 100644
--- a/src/artifacts/apple/KeyedArchiveReader.test.ts
+++ b/src/artifacts/apple/KeyedArchiveReader.test.ts
@@ -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("prototypeKey", "__proto__");
+ 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,
+ ),
+ );
+});
diff --git a/src/artifacts/apple/PlistScalars.ts b/src/artifacts/apple/PlistScalars.ts
index 60d7cb29c9..000090fbfc 100644
--- a/src/artifacts/apple/PlistScalars.ts
+++ b/src/artifacts/apple/PlistScalars.ts
@@ -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;
diff --git a/src/domain/apple/interfaceBuilderGraph.test.ts b/src/domain/apple/interfaceBuilderGraph.test.ts
index f40a117db2..47a98509a1 100644
--- a/src/domain/apple/interfaceBuilderGraph.test.ts
+++ b/src/domain/apple/interfaceBuilderGraph.test.ts
@@ -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"] },
diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.test.ts b/src/domain/apple/interfaceBuilderKeyedArchive.test.ts
index 44615b33ba..da14deb826 100644
--- a/src/domain/apple/interfaceBuilderKeyedArchive.test.ts
+++ b/src/domain/apple/interfaceBuilderKeyedArchive.test.ts
@@ -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 = {},
@@ -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),
diff --git a/src/domain/apple/keyedArchive.test.ts b/src/domain/apple/keyedArchive.test.ts
index 487be12a7c..1717c86958 100644
--- a/src/domain/apple/keyedArchive.test.ts
+++ b/src/domain/apple/keyedArchive.test.ts
@@ -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 },
);
diff --git a/src/domain/apple/plistNumbers.ts b/src/domain/apple/plistNumbers.ts
index 9aee0b80cc..f0e5bf0b87 100644
--- a/src/domain/apple/plistNumbers.ts
+++ b/src/domain/apple/plistNumbers.ts
@@ -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 (
diff --git a/src/domain/apple/plistUid.ts b/src/domain/apple/plistUid.ts
index 7e328b9e2a..d3281389a9 100644
--- a/src/domain/apple/plistUid.ts
+++ b/src/domain/apple/plistUid.ts
@@ -1,32 +1,29 @@
-/** Serialized binary UID identity is separate from an ordinary dictionary's keys. */
-const binaryUidValues = new WeakMap