mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 16:26:36 +00:00
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:
1 parent
5d6029ea62
commit
2cf9c28f27
12 files changed
+431
-55
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user