From 91c28e2735565f56df3c0564884489c17d882fbe Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:31:48 +0800 Subject: [PATCH] fix(apple): preserve XML plist UID reference identity --- docs/native-investigation.md | 8 +- .../apple/KeyedArchiveReader.test.ts | 85 +++++++++++ src/artifacts/apple/PlistScalars.ts | 6 +- .../apple/interfaceBuilderGraph.test.ts | 140 +++++++++--------- .../interfaceBuilderKeyedArchive.test.ts | 6 +- src/domain/apple/keyedArchive.test.ts | 6 +- src/domain/apple/plistNumbers.ts | 9 +- src/domain/apple/plistUid.ts | 25 ++-- src/domain/propertyListKeys.ts | 5 +- src/domain/propertyListXml.ts | 14 +- 10 files changed, 199 insertions(+), 105 deletions(-) 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(); +/** Serialized plist UID identity is separate from an ordinary dictionary's keys. */ +const plistUidValues = new WeakMap(); -/** 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; }; diff --git a/src/domain/propertyListKeys.ts b/src/domain/propertyListKeys.ts index 850bbf9f58..e062bac314 100644 --- a/src/domain/propertyListKeys.ts +++ b/src/domain/propertyListKeys.ts @@ -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 }; diff --git a/src/domain/propertyListXml.ts b/src/domain/propertyListXml.ts index f25d63d524..1d29c6d584 100644 --- a/src/domain/propertyListXml.ts +++ b/src/domain/propertyListXml.ts @@ -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 "); // 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