mirror of
https://github.com/morluto/rea.git
synced 2026-10-19 04:19:54 -05: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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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__"),
|
||||
);
|
||||
});
|
||||
Vendored
+6
@@ -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
@@ -57,7 +57,7 @@
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"kind": "dictionary",
|
||||
"kind": "scalar",
|
||||
"class_id": null,
|
||||
"class_name": null,
|
||||
"status": "observed",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user