diff --git a/scripts/verify-keyed-archive.mjs b/scripts/verify-keyed-archive.mjs index d8670964e..b9f9aa480 100644 --- a/scripts/verify-keyed-archive.mjs +++ b/scripts/verify-keyed-archive.mjs @@ -117,8 +117,41 @@ try { ).length < 2 ) throw new Error("Real shared object identity was not preserved"); + const uidRoot = join(root, "uid-root.plist"); + await promisify(execFile)("/usr/bin/xcrun", [ + "swift", + "-module-cache-path", + join(root, "modules"), + fileURLToPath( + new URL( + "../tests/conformance/native/keyed-archive-uid-root.swift", + import.meta.url, + ), + ), + uidRoot, + ]); + const uidRootXml = join(root, "uid-root.xml.plist"); + await promisify(execFile)("/usr/bin/plutil", [ + "-convert", + "xml1", + "-o", + uidRootXml, + uidRoot, + ]); + for (const path of [uidRoot, uidRootXml]) { + const uidGraph = await artifactCli("inspect-keyed-archive", path); + assert.deepEqual( + uidGraph.references.map(({ source, path, target, status }) => ({ + source, + path, + target, + status, + })), + [{ source: null, path: ["UID"], target: 1, status: "resolved" }], + ); + } process.stdout.write( - `${JSON.stringify({ ok: true, mocked: false, cli: true, stdio_mcp: true, xml_golden: true, format: graph.archive_format, objects: graph.total_objects, references: graph.total_references, shared_identity: true, cyclic_identity: true, target_classes_instantiated_by_reader: false })}\n`, + `${JSON.stringify({ ok: true, mocked: false, cli: true, stdio_mcp: true, xml_golden: true, uid_named_root: true, format: graph.archive_format, objects: graph.total_objects, references: graph.total_references, shared_identity: true, cyclic_identity: true, target_classes_instantiated_by_reader: false })}\n`, ); } finally { await rm(root, { recursive: true, force: true }); diff --git a/src/artifacts/KeyedArchiveReader.test.ts b/src/artifacts/KeyedArchiveReader.test.ts index 7dbf5e681..52fa72ef0 100644 --- a/src/artifacts/KeyedArchiveReader.test.ts +++ b/src/artifacts/KeyedArchiveReader.test.ts @@ -51,6 +51,41 @@ describe("inert keyed archive decoding", () => { expect.objectContaining({ source: 1, target: 2, status: "resolved" }), ); }); + it.each([ + ["binary", { UID: 1 }], + ["XML", { CF$UID: 1 }], + ] as const)( + "resolves a %s root encoded under a UID-named key", + (format, reference) => { + const named = { + ...archive, + $objects: ["$null", "payload"], + $top: { UID: reference, CF$UID: reference }, + }; + const graph = decodeKeyedArchiveBytes( + Buffer.from(format === "binary" ? buildBinary(named) : build(named)), + { offset: 0, limit: 2 }, + ); + expect( + graph.references.map(({ source, path, target, status }) => ({ + source, + path, + target, + status, + })), + ).toEqual([ + { source: null, path: ["CF$UID"], target: 1, status: "resolved" }, + { source: null, path: ["UID"], target: 1, status: "resolved" }, + ]); + const selected = decodeKeyedArchiveBytes( + Buffer.from(format === "binary" ? buildBinary(named) : build(named)), + { root: "UID", offset: 0, limit: 2 }, + ); + expect(selected.references).toEqual([ + expect.objectContaining({ path: ["UID"], target: 1 }), + ]); + }, + ); it("rejects missing roots, malformed plist, and non-keyed archives", () => { expect(() => decodeKeyedArchiveBytes(Buffer.from("bplist00bad"), { diff --git a/src/domain/keyedArchive.ts b/src/domain/keyedArchive.ts index 47acd0cbd..32e855f18 100644 --- a/src/domain/keyedArchive.ts +++ b/src/domain/keyedArchive.ts @@ -54,11 +54,17 @@ const record = ( value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined; -const uid = (value: JsonValue | undefined): JsonValue | undefined => { +/** + * A UID decodes as a one-key `UID` (binary) or `CF$UID` (XML) dictionary with + * a numeric value. Any other value means an ordinary dictionary, such as a + * `$top` that holds a root encoded under the key `UID`. + */ +const uid = (value: JsonValue | undefined): number | undefined => { const object = record(value); if (object === undefined || Object.keys(object).length !== 1) return undefined; - return Object.hasOwn(object, "UID") ? object.UID : object["CF$UID"]; + const marker = Object.hasOwn(object, "UID") ? object.UID : object["CF$UID"]; + return typeof marker === "number" ? marker : undefined; }; /** Interpret serialized Foundation references without instantiating archive classes. */ @@ -99,11 +105,8 @@ export const projectKeyedArchive = ( ); const target = uid(item.value); if (target !== undefined) { - const valid = - typeof target === "number" && - Number.isSafeInteger(target) && - target >= 0; - const index = valid ? target : null; + const index = + Number.isSafeInteger(target) && target >= 0 ? target : null; references.push({ source, path: item.path, @@ -143,9 +146,7 @@ export const projectKeyedArchive = ( const fields = record(value); const classUid = uid(fields?.$class); const classId = - typeof classUid === "number" && - Number.isSafeInteger(classUid) && - classUid >= 0 + classUid !== undefined && Number.isSafeInteger(classUid) && classUid >= 0 ? classUid : null; const descriptor = classId === null ? undefined : record(table[classId]); diff --git a/tests/conformance/native/keyed-archive-uid-root.swift b/tests/conformance/native/keyed-archive-uid-root.swift new file mode 100644 index 000000000..743f2dec4 --- /dev/null +++ b/tests/conformance/native/keyed-archive-uid-root.swift @@ -0,0 +1,7 @@ +import Foundation + +// Encode the root under a key that matches the UID marker spelling. +let archiver = NSKeyedArchiver(requiringSecureCoding: false) +archiver.encode("payload" as NSString, forKey: "UID") +archiver.finishEncoding() +try archiver.encodedData.write(to: URL(fileURLWithPath: CommandLine.arguments[1]))