fix(apple): preserve decoded keyed archive scalar identity

Move plist projection into the graph owner and identify generated scalar
wrappers by producer identity through every dictionary/UID consumer. Remove
the intermediate JSON clone that erased those facts. Literal wrapper-shaped
dictionaries remain dictionaries; scalar $top values fail as malformed.
Keep numeric projection after graph interpretation, preserve unknown counts,
and retain binary prototype-key partial facts with an explicit limitation.

Adapt @Jax367's relevant scalar proposal and public regression from PR #4317.
Add scalar-root, literal-root and binary prototype controls. Correct only
the captured Foundation data node expectation and its manifest digest;
original producer bytes and source are unchanged. Unrelated PR changes excluded.

Validation: 175 focused checks across 23 files pass, one skipped; build,
check:fast and docs:check pass. Public CLI and stdio MCP cover both encodings.
Baseline scalar controls fail; 24 tiny completed CLI cost comparisons retain
ordinary full replies and Evidence IDs byte for byte. No statistical speed
claim, heavy profiling, target execution or real deep-provider verification.

Fixes #4295
Fixes #4785
This commit is contained in:
morluto committed 2026-10-17 20:20:53 +08:00
1 parent 5d6029ea62
commit 2cf9c28f27
12 files changed
+431 -55

No files matched your search

+3
View File
@@ -324,6 +324,9 @@ complete observation.
shared/cyclic references, class descriptors, raw serialized fields and
malformed/unresolved/nil references. Missing fields stay absent; unknown
classes are preserved. Select `root`, `offset` and `limit` explicitly.
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`.
UID 0 cannot distinguish conditional nil from ordinary nil. Limits are
64 MiB of input, 200,000 objects/references and 128 nesting levels.
XML dictionary entries decoded as `__proto__` are omitted with an explicit
+5 -10
View File
@@ -6,10 +6,7 @@ import {
AnalysisInputError,
AnalysisUnsupportedTargetError,
} from "../../domain/analysisErrorCore.js";
import {
projectPlistValue,
unknownDateLimitation,
} from "../../domain/apple/plistValue.js";
import { unknownDateLimitation } from "../../domain/apple/plistValue.js";
import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js";
import {
decodeBinaryPlistNumbers,
@@ -85,12 +82,10 @@ export const decodeKeyedArchiveBytes = (
: 0,
}
: parseXmlPropertyList(xmlText ?? "");
const {
value: plistValue,
unknownRealCount,
unknownDateCount,
} = projectPlistValue(parsed.value);
const graph = projectKeyedArchive(plistValue, selection);
const { unknownRealCount, unknownDateCount, ...graph } = projectKeyedArchive(
parsed.value,
selection,
);
const numbers = createPlistNumberProjection(
binary
? binaryNumberMetadata!.literals
+6 -13
View File
@@ -1,7 +1,5 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { MAX_JSON_DEPTH } from "../jsonValue.js";
import { MAX_PLIST_DEPTH } from "./plistValue.js";
import { projectKeyedArchive } from "./keyedArchive.js";
const deepValue = (depth: number): unknown => {
@@ -11,18 +9,13 @@ const deepValue = (depth: number): unknown => {
};
describe("projectKeyedArchive input bound", () => {
it("rejects an over-deep archive value with a validation error instead of crashing", () => {
let error: unknown;
try {
projectKeyedArchive(deepValue(MAX_JSON_DEPTH + 1), {
it("rejects over-deep decoded input at the plist projection limit", () => {
expect(() =>
projectKeyedArchive(deepValue(MAX_PLIST_DEPTH + 1), {
offset: 0,
limit: 1,
});
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(z.ZodError);
expect(error).not.toBeInstanceOf(RangeError);
}),
).toThrow(`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`);
});
});
+28 -11
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { AnalysisInputError } from "../analysisErrorCore.js";
import { jsonValueSchema, type JsonValue } from "../jsonValue.js";
import { localPathStringSchema } from "../localPath.js";
import { projectPlistValue } from "./plistValue.js";
/**
* The selected file is intact but is another kind of file, so another tool
@@ -68,8 +69,12 @@ export const keyedArchiveResultSchema = z.strictObject({
const record = (
value: JsonValue | undefined,
wrappedScalars: WeakSet<object>,
): Record<string, JsonValue> | undefined =>
value !== null && typeof value === "object" && !Array.isArray(value)
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
!wrappedScalars.has(value)
? value
: undefined;
/**
@@ -77,8 +82,11 @@ const record = (
* 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);
const uid = (
value: JsonValue | undefined,
wrappedScalars: WeakSet<object>,
): number | undefined => {
const object = record(value, wrappedScalars);
if (object === undefined || Object.keys(object).length !== 1)
return undefined;
const marker = Object.hasOwn(object, "UID") ? object.UID : object["CF$UID"];
@@ -95,12 +103,18 @@ const archiverMismatch = (
: "its $archiver is not a string";
};
/** Interpret serialized Foundation references without instantiating archive classes. */
/** Interpret decoded Foundation values without instantiating archive classes. */
export const projectKeyedArchive = (
value: unknown,
selection: { root?: string | undefined; offset: number; limit: number },
) => {
const archive = record(jsonValueSchema.parse(value));
// Classify generated scalar wrappers by their producer identity, never by
// a caller-controlled $plist_type field in an ordinary dictionary.
const wrappedScalars = new WeakSet<object>();
const projected = projectPlistValue(value, (scalar) =>
wrappedScalars.add(scalar),
);
const archive = record(projected.value, wrappedScalars);
if (archive?.$archiver !== "NSKeyedArchiver")
throw new KeyedArchiveKindError(
`Selected property list is not an NSKeyedArchiver archive: ${archiverMismatch(archive)}`,
@@ -111,7 +125,7 @@ export const projectKeyedArchive = (
const table = archive.$objects;
if (table.length > 200_000)
throw new RangeError("Archive exceeds 200000 object-table entries");
const top = record(archive.$top);
const top = record(archive.$top, wrappedScalars);
if (top === undefined || Object.keys(top).length === 0)
throw new TypeError("Keyed archive has no $top roots");
if (selection.root !== undefined && !Object.hasOwn(top, selection.root))
@@ -140,7 +154,7 @@ export const projectKeyedArchive = (
throw new RangeError(
"Archive exceeds 200000 values per object or 128 nesting levels",
);
const target = uid(item.value);
const target = uid(item.value, wrappedScalars);
if (target !== undefined) {
const index =
Number.isSafeInteger(target) && target >= 0 ? target : null;
@@ -165,7 +179,7 @@ export const projectKeyedArchive = (
pending.push({ value: child, path: [...item.path, index] });
}
} else {
const fields = record(item.value);
const fields = record(item.value, wrappedScalars);
if (fields !== undefined)
for (const key of Object.keys(fields).sort().reverse()) {
const child = fields[key];
@@ -193,13 +207,14 @@ export const projectKeyedArchive = (
);
const objects = table.map((value, id) => {
scan(id, [{ value, path: [] }]);
const fields = record(value);
const classUid = uid(fields?.$class);
const fields = record(value, wrappedScalars);
const classUid = uid(fields?.$class, wrappedScalars);
const classId =
classUid !== undefined && Number.isSafeInteger(classUid) && classUid >= 0
? classUid
: null;
const descriptor = classId === null ? undefined : record(table[classId]);
const descriptor =
classId === null ? undefined : record(table[classId], wrappedScalars);
const className =
typeof descriptor?.$classname === "string" ? descriptor.$classname : null;
const isNil = id === 0 && value === "$null";
@@ -232,6 +247,8 @@ export const projectKeyedArchive = (
const page = objects.slice(selection.offset, end);
const ids = new Set(page.map(({ id }) => id));
return {
unknownRealCount: projected.unknownRealCount,
unknownDateCount: projected.unknownDateCount,
roots,
objects: page,
references: references.filter(
+60
View File
@@ -26,3 +26,63 @@ it("rejects excessive plist nesting without exhausting the stack", () => {
}
expect(() => projectPlistValue(deep)).toThrow(RangeError);
});
it("reports only generated scalar wrappers and keeps lookalike dictionaries distinct", () => {
const observed: object[] = [];
const value = projectPlistValue(
[
new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
new Date(0),
new Date(Number.NaN),
Number.POSITIVE_INFINITY,
{ $plist_type: "data", base64: "3q2+7w==" },
],
(wrapper) => observed.push(wrapper),
).value;
expect(Array.isArray(value)).toBe(true);
if (!Array.isArray(value)) throw new Error("Expected projected array");
expect(observed).toEqual(value.slice(0, 4));
expect(observed[0]).toBe(value[0]);
expect(value[0]).toEqual(value[4]);
expect(observed).not.toContain(value[4]);
});
it("rejects accessors, proxies, holes and symbol keys without invoking user code", () => {
let accessed = 0;
const dictionary = Object.defineProperty({}, "value", {
enumerable: true,
get() {
accessed++;
return "unexpected";
},
});
const array = Object.defineProperty([], "0", {
enumerable: true,
get() {
accessed++;
return "unexpected";
},
});
const proxy = new Proxy(
{},
{
ownKeys() {
accessed++;
return [];
},
getPrototypeOf() {
accessed++;
return Object.prototype;
},
},
);
for (const value of [
dictionary,
array,
proxy,
Array(1),
{ [Symbol("key")]: "value" },
])
expect(() => projectPlistValue(value)).toThrow(TypeError);
expect(accessed).toBe(0);
});
+46 -16
View File
@@ -1,3 +1,4 @@
import { types } from "node:util";
import { z } from "zod";
import type { JsonValue } from "../jsonValue.js";
@@ -27,38 +28,67 @@ export const unknownDateLimitation = (count: number): string =>
* `$plist_type` objects; a non-finite real becomes an explicit unknown
* because the XML decoder reports NaN and ±infinity alike.
*/
export const projectPlistValue = (value: unknown): ProjectedPlistValue => {
export const projectPlistValue = (
value: unknown,
/** Observe generated wrappers before consumers classify the JSON value tree. */
onWrappedScalar?: (value: Record<string, JsonValue>) => void,
): ProjectedPlistValue => {
let unknownRealCount = 0;
let unknownDateCount = 0;
const scalar = (value: Record<string, JsonValue>): JsonValue => {
onWrappedScalar?.(value);
return value;
};
const project = (item: unknown, depth: number): JsonValue => {
if (depth > MAX_PLIST_DEPTH)
throw new RangeError(
`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`,
);
if (item !== null && typeof item === "object" && types.isProxy(item))
throw new TypeError("Plist values cannot contain proxies");
if (item instanceof Uint8Array)
return {
return scalar({
$plist_type: "data",
base64: Buffer.from(item).toString("base64"),
};
});
if (item instanceof Date) {
if (!Number.isNaN(item.getTime()))
return { $plist_type: "date", iso8601: item.toISOString() };
if (!Number.isNaN(Date.prototype.getTime.call(item)))
return scalar({
$plist_type: "date",
iso8601: Date.prototype.toISOString.call(item),
});
unknownDateCount += 1;
return { $plist_type: "date", iso8601: null };
return scalar({ $plist_type: "date", iso8601: null });
}
if (typeof item === "number" && !Number.isFinite(item)) {
unknownRealCount += 1;
return { $plist_type: "real", value: null };
return scalar({ $plist_type: "real", value: null });
}
if (Array.isArray(item)) {
const result: JsonValue[] = [];
for (let index = 0; index < item.length; index++) {
const descriptor = Object.getOwnPropertyDescriptor(item, index);
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
throw new TypeError(
"Plist arrays require dense data properties without accessors",
);
result.push(project(descriptor.value, depth + 1));
}
return result;
}
if (item !== null && typeof item === "object") {
const entries: [string, JsonValue][] = [];
for (const key of Reflect.ownKeys(item)) {
const descriptor = Object.getOwnPropertyDescriptor(item, key);
if (descriptor?.enumerable !== true) continue;
if (typeof key !== "string" || !Object.hasOwn(descriptor, "value"))
throw new TypeError(
"Plist dictionary entries require string keys and data properties",
);
entries.push([key, project(descriptor.value, depth + 1)]);
}
return Object.fromEntries(entries);
}
if (Array.isArray(item))
return item.map((entry: unknown) => project(entry, depth + 1));
if (item !== null && typeof item === "object")
return Object.fromEntries(
Object.entries(item).map(([key, entry]) => [
key,
project(entry, depth + 1),
]),
);
return z.union([z.string(), z.number(), z.boolean(), z.null()]).parse(item);
};
const projected = project(value, 0);
@@ -0,0 +1,199 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { build, buildBinary } from "plist";
import { expect } from "vitest";
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest.for(["xml", "binary"] as const)(
"distinguishes serialized scalar objects from lookalike dictionaries in a %s keyed archive",
async (format, { cli }) => {
const root = await createTestTempDirectory("rea-keyed-scalar-kinds-");
const archive = {
$archiver: "NSKeyedArchiver",
$version: 100000,
$top: { root: { CF$UID: 12 }, data: { CF$UID: 1 } },
$objects: [
"$null",
Buffer.from("deadbeef", "hex"),
new Date("2001-01-01T00:00:00.000Z"),
9007199254740992,
"hello",
42,
1.5,
Number.POSITIVE_INFINITY,
{ $plist_type: "data", base64: "3q2+7w==" },
{ $plist_type: "date", iso8601: "2001-01-01T00:00:00.000Z" },
{ $plist_type: "real", value: 0 },
[{ CF$UID: 1 }],
{ $class: { CF$UID: 13 }, payload: { CF$UID: 1 } },
{ $classname: "ScalarHolder", $classes: ["ScalarHolder", "NSObject"] },
],
};
const bytes = Buffer.from(
format === "binary" ? buildBinary(archive) : build(archive),
);
const path = join(root, "archive.plist");
await writeFile(path, bytes);
const digest = createHash("sha256").update(bytes).digest("hex");
const run = async (arguments_: readonly string[] = []) => {
const output = await cli.run({
arguments: ["inspect-keyed-archive", path, ...arguments_, "--json"],
environment: {
HOME: root,
USERPROFILE: root,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
},
});
expect(output.exitCode, output.stderr).toBe(0);
return keyedArchiveResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
};
const result = await run();
const { call } = await connectLocalToolsMcp({
HOME: root,
USERPROFILE: root,
});
const opened = await call("open_binary", { path });
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const mcp = await call("inspect_keyed_archive", {});
expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true);
expect(
keyedArchiveResultSchema.parse(
parseEvidence(mcp.structuredContent).normalized_result,
),
).toEqual(result);
expect(result.archive_sha256).toBe(digest);
expect(result.archive_format).toBe(
format === "binary" ? "binary-plist" : "xml-plist",
);
expect(result.objects.map(({ kind }) => kind)).toEqual([
"nil",
"scalar",
"scalar",
"scalar",
"scalar",
"scalar",
"scalar",
"scalar",
"dictionary",
"dictionary",
"dictionary",
"array",
"archived-object",
"class-descriptor",
]);
for (const [scalar, dictionary] of [
[1, 8],
[2, 9],
] as const) {
expect(result.objects[scalar]?.value).toEqual(
result.objects[dictionary]?.value,
);
expect(result.objects[scalar]?.status).toBe("observed");
}
expect(result.objects[1]?.value).toEqual({
$plist_type: "data",
base64: "3q2+7w==",
});
expect(result.objects[2]?.value).toEqual({
$plist_type: "date",
iso8601: "2001-01-01T00:00:00.000Z",
});
expect(result.objects[7]?.value).toEqual({
$plist_type: "real",
value: null,
});
expect(result.objects[10]?.value).toEqual({
$plist_type: "real",
value: 0,
});
expect(result.objects[12]).toMatchObject({
class_id: 13,
class_name: "ScalarHolder",
status: "unknown-class",
});
expect(result.references).toContainEqual(
expect.objectContaining({
source: 12,
path: ["payload"],
target: 1,
status: "resolved",
}),
);
expect(result.limitations).toContainEqual(
expect.stringContaining("1 non-finite real value(s)"),
);
const page = await run(["--root", "data", "--offset", "1", "--limit", "2"]);
expect(page.objects).toEqual(result.objects.slice(1, 3));
expect(page).toMatchObject({
archive_sha256: digest,
total_objects: 14,
offset: 1,
next_offset: 3,
truncated: true,
});
expect(Object.keys(page.roots)).toEqual(["data"]);
expect(page.references).toEqual([
expect.objectContaining({
source: null,
path: ["data"],
target: 1,
status: "resolved",
}),
]);
expect(await readFile(path)).toEqual(bytes);
},
);
cliTest(
"rejects scalar top dictionaries through the public CLI and MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-keyed-scalar-top-");
const { call } = await connectLocalToolsMcp({
HOME: root,
USERPROFILE: root,
});
for (const format of ["xml", "binary"]) {
const archive = {
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null"],
$top: new Uint8Array([0]),
};
const path = join(root, format + ".plist");
await writeFile(
path,
Buffer.from(format === "xml" ? build(archive) : buildBinary(archive)),
);
const output = await cli.run({
arguments: ["inspect-keyed-archive", path, "--json"],
environment: { HOME: root, USERPROFILE: root },
});
expect(output.exitCode).toBe(1);
expect(output.json).toMatchObject({
code: "artifact_operation_failed",
details: {
reason: "format",
detail: expect.stringContaining("Keyed archive has no $top roots"),
},
});
const opened = await call("open_binary", { path });
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const reply = await call("inspect_keyed_archive", {});
expect(reply.isError).toBe(true);
expect(JSON.stringify(reply)).toContain(
"Keyed archive has no $top roots",
);
}
},
);
@@ -33,11 +33,14 @@ cliTest(
const result = keyedArchiveResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
expect(result.objects.map(({ id, value }) => ({ id, value }))).toEqual([
{ id: 0, value: "$null" },
{ id: 1, value: { $plist_type: "date", iso8601: null } },
expect(
result.objects.map(({ id, kind, value }) => ({ id, kind, value })),
).toEqual([
{ id: 0, kind: "nil", value: "$null" },
{ id: 1, kind: "scalar", value: { $plist_type: "date", iso8601: null } },
{
id: 2,
kind: "scalar",
value: { $plist_type: "date", iso8601: "2020-01-01T00:00:00.000Z" },
},
]);
@@ -0,0 +1,70 @@
import { build, buildBinary } from "plist";
import { expect, it } from "vitest";
import { decodeKeyedArchiveBytes } from "../../../src/artifacts/apple/KeyedArchiveReader.js";
it.each([
{ name: "data", value: new Uint8Array([0]) },
{ name: "date", value: new Date(0) },
{ name: "real", value: Number.POSITIVE_INFINITY },
])(
"rejects a $name scalar used as the top-level root dictionary in either encoding",
({ value }) => {
const archive = {
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null"],
$top: value,
};
for (const encode of [build, buildBinary]) {
const bytes = Buffer.from(encode(archive));
expect(() =>
decodeKeyedArchiveBytes(bytes, { offset: 0, limit: 10 }),
).toThrow("Keyed archive has no $top roots");
}
},
);
it("keeps literal wrapper-shaped dictionaries as real root maps", () => {
const top = { $plist_type: "data", base64: "AA==" };
const archive = {
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null"],
$top: top,
};
for (const encode of [build, buildBinary]) {
const graph = decodeKeyedArchiveBytes(Buffer.from(encode(archive)), {
offset: 0,
limit: 10,
});
expect(graph.roots).toEqual(top);
expect(graph.references).toEqual([]);
}
});
it("retains own binary dictionary facts when a prototype key changes the decoder prototype", () => {
const object = Object.fromEntries([
["__proto__", { inherited: "not an own fact" }],
["payload", "retained"],
]);
const graph = decodeKeyedArchiveBytes(
Buffer.from(
buildBinary({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", object],
$top: { root: { UID: 1 } },
}),
),
{ offset: 0, limit: 10 },
);
expect(graph.objects[1]).toMatchObject({
kind: "dictionary",
value: { payload: "retained" },
});
expect(graph.objects[1]?.value).toEqual({ payload: "retained" });
expect(graph.limitations).toContainEqual(
expect.stringContaining("__proto__"),
);
});
+6
View File
@@ -19,6 +19,12 @@ its nil conditional reference and the payload `00 01 02 ff`. The reader reports
serialized classes as unknown and does not instantiate them. The synthetic
malformed-reference tests remain separate.
Object 3 is a plist `<data>` value in the captured XML, produced by the Swift
fixture's `Data([0, 1, 2, 255])`. Its reviewed graph kind is `scalar`; the JSON
`$plist_type` wrapper does not make it a serialized dictionary. This expectation
correction updates only the graph digest in `provenance.json`, without
recapturing or changing the original XML or its producer metadata.
To recapture the input on an approved macOS host:
```sh
+1 -1
View File
@@ -57,7 +57,7 @@
},
{
"id": 3,
"kind": "dictionary",
"kind": "scalar",
"class_id": null,
"class_name": null,
"status": "observed",
+1 -1
View File
@@ -16,7 +16,7 @@
"tests/conformance/native/Assets.xcassets/Contents.json": "67328f1f20917d61b0904d1eeadd3a9af5ebdfeb20bdd8c6f585f97f7192d873",
"tests/conformance/native/Assets.xcassets/FixtureColor.colorset/Contents.json": "26c6400ce2082ddc155175e80130daddded1a863bd68fe441bee413cc2786731",
"tests/fixtures/golden/keyed-archive/foundation.xml": "93c2bf33ad960be6fba6d45c28462c4bda73ff371fe16725d087087f044479ec",
"tests/fixtures/golden/keyed-archive/graph.json": "eaadd9fca83052e81abd0dac797d2423b0bb750bea386f790586273eb3a3172e",
"tests/fixtures/golden/keyed-archive/graph.json": "d761330d5a3ca89a51eaaacaa5d43b92cda2ee544931b8b67849b2e937cf848a",
"tests/fixtures/golden/assetutil.json": "ff50e24963447584e3398dddf4b0426274ffd2ea2d3ae6fac8295f449f91feb3",
"tests/fixtures/golden/mcp-initialize.json": "8f590db5143106aa43dfa96123178484d98268874eff7fabbd708c240c5975bc"
}