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

+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);