fix(artifacts): read keyed archive dictionaries keyed UID as dictionaries

Foundation encodes `encode(_:forKey: "UID")` as a `$top` dictionary
whose single key is `UID`. The keyed archive projection treated every
one-key `UID`/`CF$UID` dictionary as a reference marker, so that root
became a malformed reference and the real link to its object was lost.

Treat a one-key dictionary as a UID only when its value is a number,
matching the binary decoder's UID shape and Foundation's XML `CF$UID`
rule; otherwise descend into it as an ordinary dictionary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iwakurakaoru0822 2026-10-06 22:46:16 -07:00
parent 676959ecb5
commit 35c82b93eb
4 changed files with 87 additions and 11 deletions

View File

@ -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 });

View File

@ -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"), {

View File

@ -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]);

View File

@ -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]))