mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
fix(plist): own dictionary decoding across Apple workflows
Preserve XML, OpenStep and binary dictionary keys at their format owners. Share validated binary reference facts with materialization while retaining scalar precision, UID identity and bounded expansion. Migrate archive, Interface Builder, native and app-bundle consumers, then remove obsolete key-omission accounting and schema copies. Validated source regressions and compiled CLI/MCP workflows, including native plist fidelity and archive identity. Typecheck, lint, docs and test discovery pass. No performance benchmarks were required.
This commit is contained in:
@@ -641,11 +641,10 @@ complete observation.
|
||||
outcome instead of rounded reference identities.
|
||||
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
|
||||
count because REA results cannot represent that key. XML tag whitespace and
|
||||
comments do not change that handling. XML key names combine their text and
|
||||
CDATA segments without trimming or expanding literal CDATA entities. Other
|
||||
values and the original archive digest are preserved.
|
||||
XML, OpenStep and binary dictionary entries retain their source keys,
|
||||
including `__proto__`, through archive projection and CLI/MCP results.
|
||||
XML key names combine their text and CDATA segments without trimming or
|
||||
expanding literal CDATA entities. The original archive digest is preserved.
|
||||
Symbol fallback accepts complete Objective-C class/metaclass symbol leaves,
|
||||
including qualified names. Pointer/string labels that merely contain a class
|
||||
marker do not establish classes. Provider namespace method labels are not
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { parseBinary } from "plist";
|
||||
import { z } from "zod";
|
||||
|
||||
import { preflightPropertyListDecode } from "../artifacts/apple/PropertyListDecodeBudget.js";
|
||||
import { decodeBinaryPlistUids } from "../artifacts/apple/PlistScalars.js";
|
||||
import { decodeBinaryPlistValue } from "../artifacts/apple/BinaryPlistMaterializer.js";
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { isPathWithinRoot } from "../domain/localPath.js";
|
||||
import {
|
||||
omitPrototypeKeys,
|
||||
parseXmlPropertyList,
|
||||
} from "../domain/propertyListKeys.js";
|
||||
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
|
||||
import { decodeXmlPlistText } from "../domain/propertyListXmlText.js";
|
||||
import { hasBinaryPlistSignature } from "../domain/apple/appleArchiveFormat.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -355,13 +351,7 @@ const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() });
|
||||
/** Decode a binary plist in-process, so no host plist tool is required. */
|
||||
const parseBinaryPlistExecutable = (plist: Buffer): string => {
|
||||
const plan = preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES);
|
||||
// `plist` assigns a `__proto__` key as the prototype; copying drops it, as
|
||||
// the XML path does.
|
||||
return executableName(
|
||||
omitPrototypeKeys(
|
||||
parseBinary(decodeBinaryPlistUids(plist, plan.uidOffsetsToNormalize)),
|
||||
).value,
|
||||
);
|
||||
return executableName(decodeBinaryPlistValue(plist, plan));
|
||||
};
|
||||
|
||||
const isSafeExecutableName = (name: string): boolean =>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { binaryPlistObjectTable } from "../../../tests/fixtures/binaryPlistObjectTable.js";
|
||||
import { plistUidValue } from "../../domain/apple/plistUid.js";
|
||||
import { decodeBinaryPlistValue } from "./BinaryPlistMaterializer.js";
|
||||
import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js";
|
||||
|
||||
const source = (objects: readonly Buffer[]): Buffer => {
|
||||
const table =
|
||||
8 + objects.reduce((length, object) => length + object.length, 0);
|
||||
const bytes = Buffer.alloc(table + objects.length + 32);
|
||||
let cursor = 8;
|
||||
const offsets = objects.map((object) => {
|
||||
const offset = cursor;
|
||||
object.copy(bytes, cursor);
|
||||
cursor += object.length;
|
||||
return offset;
|
||||
});
|
||||
return binaryPlistObjectTable(bytes, offsets, table);
|
||||
};
|
||||
const text = (value: string): Buffer =>
|
||||
Buffer.concat([Buffer.from([0x50 | value.length]), Buffer.from(value)]);
|
||||
const decode = (bytes: Buffer) =>
|
||||
decodeBinaryPlistValue(
|
||||
bytes,
|
||||
estimateBinaryPlistExpansion(bytes, 1024 * 1024, {
|
||||
uidProjection: true,
|
||||
numberProjection: true,
|
||||
}),
|
||||
);
|
||||
|
||||
it("materializes own dictionary keys and keeps the last duplicate's source UID", () => {
|
||||
const bytes = source([
|
||||
Buffer.from([0xd3, 1, 2, 1, 3, 4, 5]),
|
||||
text("__proto__"),
|
||||
text("constructor"),
|
||||
Buffer.from([0x80, 1]),
|
||||
text("kept"),
|
||||
Buffer.from([0x80, 2]),
|
||||
]);
|
||||
const value = decode(bytes) as Record<string, unknown>;
|
||||
expect(Object.keys(value)).toEqual(["__proto__", "constructor"]);
|
||||
expect(Object.getPrototypeOf(value)).toBe(Object.prototype);
|
||||
expect(plistUidValue(value["__proto__"])).toBe(2);
|
||||
expect(value.constructor).toBe("kept");
|
||||
});
|
||||
|
||||
it("expands shared containers and nonprimitive scalar references independently", () => {
|
||||
const date = Buffer.alloc(9);
|
||||
date[0] = 0x33;
|
||||
date.writeDoubleBE(1, 1);
|
||||
const bytes = source([
|
||||
Buffer.from([0xa8, 1, 1, 2, 2, 3, 3, 4, 4]),
|
||||
Buffer.from([0xd1, 5, 6]),
|
||||
date,
|
||||
Buffer.from([0x42, 1, 2]),
|
||||
Buffer.from([0x80, 7]),
|
||||
text("nested"),
|
||||
text("kept"),
|
||||
]);
|
||||
const value = decode(bytes) as unknown[];
|
||||
for (let index = 0; index < value.length; index += 2) {
|
||||
expect(value[index]).toEqual(value[index + 1]);
|
||||
expect(value[index]).not.toBe(value[index + 1]);
|
||||
}
|
||||
expect(plistUidValue(value[6])).toBe(7);
|
||||
expect(plistUidValue(value[7])).toBe(7);
|
||||
});
|
||||
|
||||
it("keeps a source UID distinct from an ordinary UID-shaped dictionary", () => {
|
||||
const bytes = source([
|
||||
Buffer.from([0xa2, 1, 2]),
|
||||
Buffer.from([0x80, 7]),
|
||||
Buffer.from([0xd1, 3, 4]),
|
||||
text("UID"),
|
||||
Buffer.from([0x10, 7]),
|
||||
]);
|
||||
const value = decode(bytes) as unknown[];
|
||||
expect(value[0]).toEqual({ UID: 7 });
|
||||
expect(value[1]).toEqual({ UID: 7 });
|
||||
expect(plistUidValue(value[0])).toBe(7);
|
||||
expect(plistUidValue(value[1])).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { parseBinary } from "plist";
|
||||
import { markPlistDictionary } from "../../domain/apple/plistUid.js";
|
||||
import {
|
||||
binaryPlistObjectOffsets,
|
||||
decodeBinaryPlistScalars,
|
||||
scanBinaryPlistScalars,
|
||||
type BinaryPlistScalarMetadata,
|
||||
} from "./PlistScalars.js";
|
||||
import type { PlistDecodePreflight } from "./PropertyListDecodeBudget.js";
|
||||
|
||||
/** Build one array of source scalar references; upstream never constructs a dictionary. */
|
||||
const scalarArrayInput = (
|
||||
bytes: Buffer,
|
||||
objects: readonly number[],
|
||||
): Buffer => {
|
||||
const trailer = bytes.length - 32;
|
||||
const referenceWidth = bytes[trailer + 7]!;
|
||||
const objectCount = Number(bytes.readBigUInt64BE(trailer + 8));
|
||||
const rootOffset = bytes.length;
|
||||
const rootLength = 10 + objects.length * referenceWidth;
|
||||
const tableOffset = rootOffset + rootLength;
|
||||
const output = Buffer.alloc(tableOffset + (objectCount + 1) * 8 + 32);
|
||||
bytes.copy(output);
|
||||
output[rootOffset] = 0xaf;
|
||||
output[rootOffset + 1] = 0x13;
|
||||
output.writeBigUInt64BE(BigInt(objects.length), rootOffset + 2);
|
||||
for (const [index, object] of objects.entries()) {
|
||||
let reference = BigInt(object);
|
||||
const offset = rootOffset + 10 + index * referenceWidth;
|
||||
for (let byte = referenceWidth - 1; byte >= 0; byte -= 1) {
|
||||
output[offset + byte] = Number(reference & 0xffn);
|
||||
reference >>= 8n;
|
||||
}
|
||||
}
|
||||
let object = 0;
|
||||
for (const offset of binaryPlistObjectOffsets(bytes)) {
|
||||
output.writeBigUInt64BE(offset, tableOffset + object * 8);
|
||||
object += 1;
|
||||
}
|
||||
output.writeBigUInt64BE(BigInt(rootOffset), tableOffset + objectCount * 8);
|
||||
const nextTrailer = output.length - 32;
|
||||
output[nextTrailer + 6] = 8;
|
||||
output[nextTrailer + 7] = referenceWidth;
|
||||
output.writeBigUInt64BE(BigInt(objectCount + 1), nextTrailer + 8);
|
||||
output.writeBigUInt64BE(BigInt(objectCount), nextTrailer + 16);
|
||||
output.writeBigUInt64BE(BigInt(tableOffset), nextTrailer + 24);
|
||||
return output;
|
||||
};
|
||||
|
||||
/** Preserve each source reference's scalar identity while retaining upstream scalar semantics. */
|
||||
const scalarOccurrence = (
|
||||
value: unknown,
|
||||
uid: boolean,
|
||||
identity: boolean,
|
||||
): unknown => {
|
||||
if (value instanceof Date) return new Date(value.getTime());
|
||||
if (value instanceof Uint8Array)
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
if (uid && value !== null && typeof value === "object") {
|
||||
const copy = { ...value };
|
||||
if (identity) markPlistDictionary(copy, (value as { UID: number }).UID);
|
||||
return copy;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Decode validated source containers without losing dictionary keys or alias occurrence identity. */
|
||||
export const decodeBinaryPlistValue = (
|
||||
bytes: Buffer,
|
||||
plan: PlistDecodePreflight,
|
||||
metadata: BinaryPlistScalarMetadata = scanBinaryPlistScalars(bytes, plan),
|
||||
): unknown => {
|
||||
const source = plan.binaryValuePlan;
|
||||
if (source === undefined)
|
||||
throw new TypeError("Binary plist source plan is missing");
|
||||
const compatible = decodeBinaryPlistScalars(bytes, metadata, plan);
|
||||
const scalars = parseBinary(
|
||||
scalarArrayInput(
|
||||
compatible,
|
||||
source.scalars.map(({ object }) => object),
|
||||
),
|
||||
) as unknown[];
|
||||
const scalarIndex = new Map(
|
||||
source.scalars.map((scalar, index) => [scalar.object, index]),
|
||||
);
|
||||
const containers = new Map(
|
||||
source.containers.map((container) => [container.object, container]),
|
||||
);
|
||||
const root: { value?: unknown } = {};
|
||||
const pending: { object: number; parent: object; key: string | number }[] = [
|
||||
{ object: source.topObject, parent: root, key: "value" },
|
||||
];
|
||||
while (pending.length > 0) {
|
||||
const entry = pending.pop()!;
|
||||
const container = containers.get(entry.object);
|
||||
let value: unknown;
|
||||
if (container === undefined) {
|
||||
const index = scalarIndex.get(entry.object);
|
||||
if (index === undefined)
|
||||
throw new TypeError("Binary plist scalar source is missing");
|
||||
value = scalarOccurrence(
|
||||
scalars[index],
|
||||
source.scalars[index]!.uid,
|
||||
source.uidIdentity,
|
||||
);
|
||||
} else {
|
||||
value = container.kind === "array" ? [] : {};
|
||||
if (container.kind === "dictionary" && source.uidIdentity)
|
||||
markPlistDictionary(value as object);
|
||||
for (let index = container.references.length - 1; index >= 0; index -= 1)
|
||||
pending.push({
|
||||
object: container.references[index]!,
|
||||
parent: value as object,
|
||||
key: container.keys?.[index] ?? index,
|
||||
});
|
||||
}
|
||||
Object.defineProperty(entry.parent, entry.key, {
|
||||
value,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
return root.value;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { TextDecoder } from "node:util";
|
||||
import { parseBinary } from "plist";
|
||||
|
||||
import { decodeBinaryPlistValue } from "./BinaryPlistMaterializer.js";
|
||||
|
||||
import { appleResourceKeyFields } from "../../domain/apple/appleAssetCatalog.js";
|
||||
import {
|
||||
@@ -23,16 +24,16 @@ import {
|
||||
unknownDateLimitation,
|
||||
unknownScalarLimitation,
|
||||
} from "../../domain/apple/plistValue.js";
|
||||
import {
|
||||
omittedPrototypeKeysLimitation,
|
||||
parseXmlPropertyList,
|
||||
} from "../../domain/propertyListKeys.js";
|
||||
import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
|
||||
import {
|
||||
decodeNibArchive,
|
||||
nibCollectionMembers,
|
||||
type NibArchiveDocument,
|
||||
} from "./NibArchive.js";
|
||||
import { preflightPropertyListDecode } from "./PropertyListDecodeBudget.js";
|
||||
import {
|
||||
preflightPropertyListDecode,
|
||||
type PlistDecodePreflight,
|
||||
} from "./PropertyListDecodeBudget.js";
|
||||
import {
|
||||
interfaceBuilderDecodeBudgetFailure,
|
||||
InterfaceBuilderDecodeBudget,
|
||||
@@ -58,11 +59,8 @@ import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"
|
||||
|
||||
import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
|
||||
import {
|
||||
decodeBinaryPlistScalars,
|
||||
retainBinaryPlistUidIdentity,
|
||||
scanBinaryPlistScalars,
|
||||
UnsupportedBinaryPlistUidError,
|
||||
type BinaryPlistScalarDecodePlan,
|
||||
} from "./PlistScalars.js";
|
||||
import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js";
|
||||
import {
|
||||
@@ -111,7 +109,6 @@ const decodeInterfaceBuilderBundle = async (
|
||||
const incompleteHierarchies = new Map<string, number>();
|
||||
const unexaminedSources = new Map<string, readonly NibUnexaminedSource[]>();
|
||||
const unresolvedConnections = new Map<string, number>();
|
||||
const prototypeKeyOmissions = new Map<string, number | null>();
|
||||
const unknownDates = new Map<string, number>();
|
||||
const datePrecisionOverlaps = new Map<string, number>();
|
||||
const unknownScalars = new Map<string, number>();
|
||||
@@ -178,15 +175,7 @@ const decodeInterfaceBuilderBundle = async (
|
||||
const isNibArchive = hasNibArchiveSignature(bytes);
|
||||
let nib: ReturnType<typeof projectNibArchive> | null = null;
|
||||
let xmlText: string | undefined;
|
||||
let binaryPrototypeKeyObserved = false;
|
||||
let scalarPlan: BinaryPlistScalarDecodePlan = {
|
||||
reachableDateOffsets: [],
|
||||
overlappingDateOffsets: [],
|
||||
reachableWideIntegerOffsets: [],
|
||||
overlappingWideIntegerOffsets: [],
|
||||
reachableNumericOffsets: [],
|
||||
uidOffsetsToNormalize: [],
|
||||
};
|
||||
let scalarPlan!: PlistDecodePreflight;
|
||||
let datePrecisionOverlapCount = 0;
|
||||
if (isNibArchive) {
|
||||
const documentBudget = new InterfaceBuilderDecodeBudget(
|
||||
@@ -220,23 +209,19 @@ const decodeInterfaceBuilderBundle = async (
|
||||
"plist representation exceeds the aggregate Interface Builder decode budget",
|
||||
);
|
||||
plistReservation = decodedReservation.estimatedBytes;
|
||||
binaryPrototypeKeyObserved =
|
||||
decodedReservation.binaryPrototypeKeyObserved;
|
||||
scalarPlan = decodedReservation;
|
||||
datePrecisionOverlapCount =
|
||||
decodedReservation.datePrecisionOverlapCount;
|
||||
}
|
||||
const {
|
||||
value: raw,
|
||||
omittedPrototypeKeys,
|
||||
unknownDateCount,
|
||||
unknownScalarCount,
|
||||
numberLiterals,
|
||||
} = nib === null
|
||||
? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved, scalarPlan)
|
||||
? decodePlist(bytes, xmlText, scalarPlan)
|
||||
: {
|
||||
value: nib.raw,
|
||||
omittedPrototypeKeys: 0,
|
||||
unknownDateCount: 0,
|
||||
unknownScalarCount: 0,
|
||||
numberLiterals: undefined,
|
||||
@@ -252,8 +237,6 @@ const decodeInterfaceBuilderBundle = async (
|
||||
nonFiniteReals.set(entry.path, nib.nonFiniteRealCount);
|
||||
if (nib !== null && nib.negativeZeroRealCount > 0)
|
||||
negativeZeroReals.set(entry.path, nib.negativeZeroRealCount);
|
||||
if (omittedPrototypeKeys !== 0)
|
||||
prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys);
|
||||
if (unknownDateCount !== 0)
|
||||
unknownDates.set(entry.path, unknownDateCount);
|
||||
if (datePrecisionOverlapCount > 0)
|
||||
@@ -321,7 +304,6 @@ const decodeInterfaceBuilderBundle = async (
|
||||
unexaminedSources,
|
||||
incompleteHierarchies,
|
||||
unresolvedConnections,
|
||||
prototypeKeyOmissions,
|
||||
unknownDates,
|
||||
datePrecisionOverlaps,
|
||||
unknownScalars,
|
||||
@@ -423,7 +405,6 @@ const finalizeHierarchyCoverage = (
|
||||
unexaminedSources: ReadonlyMap<string, readonly NibUnexaminedSource[]>;
|
||||
incompleteHierarchies: ReadonlyMap<string, number>;
|
||||
unresolvedConnections: ReadonlyMap<string, number>;
|
||||
prototypeKeyOmissions: ReadonlyMap<string, number | null>;
|
||||
unknownDates: ReadonlyMap<string, number>;
|
||||
datePrecisionOverlaps: ReadonlyMap<string, number>;
|
||||
unknownScalars: ReadonlyMap<string, number>;
|
||||
@@ -441,7 +422,6 @@ const finalizeHierarchyCoverage = (
|
||||
unexaminedSources,
|
||||
incompleteHierarchies,
|
||||
unresolvedConnections,
|
||||
prototypeKeyOmissions,
|
||||
unknownDates,
|
||||
datePrecisionOverlaps,
|
||||
unknownScalars,
|
||||
@@ -454,7 +434,6 @@ const finalizeHierarchyCoverage = (
|
||||
const archiveDecodePartial =
|
||||
invalid.length > 0 ||
|
||||
unexaminedSources.size > 0 ||
|
||||
prototypeKeyOmissions.size > 0 ||
|
||||
counts.omitted > 0 ||
|
||||
counts.traversal.status === "unknown";
|
||||
return {
|
||||
@@ -521,13 +500,11 @@ const finalizeHierarchyCoverage = (
|
||||
? "archive_decode_budget_exhausted"
|
||||
: invalid.length > 0
|
||||
? "one_or_more_archives_invalid"
|
||||
: prototypeKeyOmissions.size > 0
|
||||
? "dictionary_entries_omitted"
|
||||
: unexaminedSources.size > 0
|
||||
? "serialized_archive_content_unexamined"
|
||||
: counts.traversal.status === "unknown"
|
||||
? "source_traversal_unknown"
|
||||
: null,
|
||||
: unexaminedSources.size > 0
|
||||
? "serialized_archive_content_unexamined"
|
||||
: counts.traversal.status === "unknown"
|
||||
? "source_traversal_unknown"
|
||||
: null,
|
||||
examined: counts.attempted,
|
||||
omitted: counts.omitted,
|
||||
},
|
||||
@@ -560,9 +537,6 @@ const finalizeHierarchyCoverage = (
|
||||
: [
|
||||
`Some Interface Builder archives could not be decoded: ${invalid.slice(0, 16).join("; ")}`,
|
||||
]),
|
||||
...[...prototypeKeyOmissions].map(
|
||||
([path, count]) => `${path}: ${omittedPrototypeKeysLimitation(count)}`,
|
||||
),
|
||||
...[...unknownDates].map(
|
||||
([path, count]) => `${path}: ${unknownDateLimitation(count)}`,
|
||||
),
|
||||
@@ -1075,15 +1049,13 @@ const parseXmlWithinDecodeBudget = (text: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Project decoded plist data and dates while retaining omitted-key coverage. */
|
||||
/** Project decoded plist data and dates while retaining scalar coverage. */
|
||||
const decodePlist = (
|
||||
bytes: Buffer,
|
||||
xmlText: string | undefined,
|
||||
binaryPrototypeKeyObserved: boolean,
|
||||
scalarPlan: BinaryPlistScalarDecodePlan,
|
||||
scalarPlan: PlistDecodePreflight,
|
||||
): {
|
||||
readonly value: JsonValue;
|
||||
readonly omittedPrototypeKeys: number | null;
|
||||
readonly unknownDateCount: number;
|
||||
readonly unknownScalarCount: number;
|
||||
readonly numberLiterals: PlistNumberLiterals;
|
||||
@@ -1092,22 +1064,15 @@ const decodePlist = (
|
||||
const binaryScalarMetadata = binary
|
||||
? scanBinaryPlistScalars(bytes, scalarPlan)
|
||||
: undefined;
|
||||
const { value, omittedPrototypeKeys, numberLiterals } = binary
|
||||
const { value, numberLiterals } = binary
|
||||
? {
|
||||
value: retainBinaryPlistUidIdentity(
|
||||
parseBinary(
|
||||
decodeBinaryPlistScalars(bytes, binaryScalarMetadata!, scalarPlan),
|
||||
),
|
||||
scalarPlan,
|
||||
),
|
||||
omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0,
|
||||
value: decodeBinaryPlistValue(bytes, scalarPlan, binaryScalarMetadata),
|
||||
numberLiterals: binaryScalarMetadata!.literals,
|
||||
}
|
||||
: parseXmlWithinDecodeBudget(xmlText ?? decodeXmlPlistText(bytes));
|
||||
const projected = projectPlistValue(value);
|
||||
return {
|
||||
value: projected.value,
|
||||
omittedPrototypeKeys,
|
||||
unknownDateCount: projected.unknownDateCount,
|
||||
unknownScalarCount: projected.unknownScalarCount,
|
||||
numberLiterals,
|
||||
|
||||
@@ -82,10 +82,9 @@ describe("inert XML declaration examples in Interface Builder archives", () => {
|
||||
({ xml }) => {
|
||||
expect(() => parseXmlPropertyList(xml)).not.toThrow();
|
||||
expect(
|
||||
preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024),
|
||||
).toMatchObject({
|
||||
binaryPrototypeKeyObserved: false,
|
||||
});
|
||||
preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024)
|
||||
.estimatedBytes,
|
||||
).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -158,8 +157,9 @@ describe("inert XML declaration examples in Interface Builder archives", () => {
|
||||
const xml = archive("Build").replace("<plist", `${declaration}<plist`);
|
||||
expect(() => parseXmlPropertyList(xml)).not.toThrow();
|
||||
expect(
|
||||
preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024),
|
||||
).toMatchObject({ binaryPrototypeKeyObserved: false });
|
||||
preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024)
|
||||
.estimatedBytes,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -232,27 +232,29 @@ describe("inert keyed archive decoding", () => {
|
||||
expect.objectContaining({ source: 1, target: 2, status: "resolved" }),
|
||||
);
|
||||
});
|
||||
it("reports an XML archive dictionary keyed __proto__ as omitted", () => {
|
||||
const xml = build({ ...archive, $objects: ["$null", "value"] }).replace(
|
||||
"<key>$top</key>",
|
||||
"<key>__proto__</key><string>x</string><key>$top</key>",
|
||||
);
|
||||
it("preserves an XML object dictionary keyed __proto__", () => {
|
||||
const xml = build({
|
||||
...archive,
|
||||
$objects: ["$null", { prototypeKey: "x" }],
|
||||
}).replace("<key>prototypeKey</key>", "<key>__proto__</key>");
|
||||
const graph = decodeKeyedArchiveBytes(Buffer.from(xml), {
|
||||
offset: 0,
|
||||
limit: 2,
|
||||
});
|
||||
expect(graph.objects.map(({ value }) => value)).toEqual(["$null", "value"]);
|
||||
expect(graph.limitations).toContain(
|
||||
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(graph.objects.map(({ value }) => value)).toEqual([
|
||||
"$null",
|
||||
{ ["__proto__"]: "x" },
|
||||
]);
|
||||
expect(
|
||||
graph.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
});
|
||||
it("reports a binary archive dictionary keyed __proto__ as omitted", () => {
|
||||
it("preserves a binary object dictionary keyed __proto__", () => {
|
||||
const placeholder = "proto_key";
|
||||
const encoded = Buffer.from(
|
||||
buildBinary({
|
||||
...archive,
|
||||
[placeholder]: "hidden",
|
||||
$objects: ["$null", "value"],
|
||||
$objects: ["$null", { [placeholder]: "hidden" }],
|
||||
}),
|
||||
);
|
||||
const start = encoded.indexOf(placeholder);
|
||||
@@ -261,9 +263,10 @@ describe("inert keyed archive decoding", () => {
|
||||
Buffer.from("__proto__").copy(encoded, start);
|
||||
|
||||
const graph = decodeKeyedArchiveBytes(encoded, { offset: 0, limit: 2 });
|
||||
expect(graph.limitations).toContain(
|
||||
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(graph.objects[1]?.value).toEqual({ ["__proto__"]: "hidden" });
|
||||
expect(
|
||||
graph.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
});
|
||||
it("does not report a binary __proto__ value as an omitted key", () => {
|
||||
const graph = decodeKeyedArchiveBytes(
|
||||
@@ -276,9 +279,9 @@ describe("inert keyed archive decoding", () => {
|
||||
),
|
||||
{ offset: 0, limit: 2 },
|
||||
);
|
||||
expect(graph.limitations).not.toContain(
|
||||
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(
|
||||
graph.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
});
|
||||
it("rejects missing roots, malformed plist, and non-keyed archives", () => {
|
||||
expect(() =>
|
||||
@@ -447,6 +450,10 @@ describe("binary plist reference preflight", () => {
|
||||
[2, "$null"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binary plist dictionary materialization", () => {
|
||||
const selection = { offset: 0, limit: 10 };
|
||||
|
||||
it("recognizes a shared key object used by separate dictionaries", () => {
|
||||
const graph = decodeKeyedArchiveBytes(
|
||||
@@ -460,18 +467,24 @@ describe("binary plist reference preflight", () => {
|
||||
]),
|
||||
selection,
|
||||
);
|
||||
expect(graph.limitations).toContain(
|
||||
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(graph.objects.map(({ value }) => value)).toEqual([
|
||||
{ ["__proto__"]: "first" },
|
||||
{ ["__proto__"]: "second" },
|
||||
]);
|
||||
expect(
|
||||
graph.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reports binary omission presence without claiming a count", () => {
|
||||
it("preserves nested keys while duplicate entries retain the last value", () => {
|
||||
const placeholder = "proto_key";
|
||||
const nested = Buffer.from(
|
||||
buildBinary({
|
||||
...archive,
|
||||
[placeholder]: { [placeholder]: "hidden", visible: true },
|
||||
$objects: ["$null", "value"],
|
||||
$objects: [
|
||||
"$null",
|
||||
{ [placeholder]: { [placeholder]: "hidden", visible: true } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
let index = nested.indexOf(placeholder);
|
||||
@@ -480,9 +493,14 @@ describe("binary plist reference preflight", () => {
|
||||
index = nested.indexOf(placeholder, index + placeholder.length);
|
||||
}
|
||||
const nestedGraph = decodeKeyedArchiveBytes(nested, selection);
|
||||
expect(nestedGraph.limitations).toContain(
|
||||
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(nestedGraph.objects[1]?.value).toEqual({
|
||||
["__proto__"]: { ["__proto__"]: "hidden", visible: true },
|
||||
});
|
||||
expect(
|
||||
nestedGraph.limitations.some((value) =>
|
||||
value.includes("keyed __proto__"),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const shadowedValue = keyedRoot([
|
||||
plistArray([9]),
|
||||
@@ -497,12 +515,7 @@ describe("binary plist reference preflight", () => {
|
||||
expect(keyOffset).toBeGreaterThanOrEqual(0);
|
||||
Buffer.from("__proto__").copy(shadowedValue, keyOffset);
|
||||
const shadowedGraph = decodeKeyedArchiveBytes(shadowedValue, selection);
|
||||
expect(shadowedGraph.limitations).toContain(
|
||||
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(shadowedGraph.limitations).not.toContain(
|
||||
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(shadowedGraph.objects[0]?.value).toEqual({ shadow: "replacement" });
|
||||
});
|
||||
|
||||
it("detects UTF-16 binary dictionary keys", () => {
|
||||
@@ -515,9 +528,10 @@ describe("binary plist reference preflight", () => {
|
||||
]),
|
||||
selection,
|
||||
);
|
||||
expect(graph.limitations).toContain(
|
||||
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(graph.objects[0]?.value).toEqual({ ["__proto__"]: "hidden" });
|
||||
expect(
|
||||
graph.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("detects extended-length UTF-16 binary dictionary keys", () => {
|
||||
@@ -530,9 +544,10 @@ describe("binary plist reference preflight", () => {
|
||||
]),
|
||||
selection,
|
||||
);
|
||||
expect(graph.limitations).toContain(
|
||||
"Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(graph.objects[0]?.value).toEqual({ ["__proto__"]: "hidden" });
|
||||
expect(
|
||||
graph.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -893,7 +908,7 @@ it("preserves exact integers in XML authored UID dictionaries", () => {
|
||||
expect(graph.total_references).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves XML dictionary identity when prototype-key omission leaves a CF$UID field", () => {
|
||||
it("preserves XML dictionary identity beside an authored CF$UID field", () => {
|
||||
const xml = build({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$objects: [
|
||||
@@ -907,7 +922,9 @@ it("preserves XML dictionary identity when prototype-key omission leaves a CF$UI
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
});
|
||||
expect(graph.objects[1]?.value).toEqual({ authoredRecord: { CF$UID: 2 } });
|
||||
expect(graph.objects[1]?.value).toEqual({
|
||||
authoredRecord: { CF$UID: 2, ["__proto__"]: "authored" },
|
||||
});
|
||||
expect(graph.total_references).toBe(2);
|
||||
expect(graph.references).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -923,9 +940,7 @@ it("preserves XML dictionary identity when prototype-key omission leaves a CF$UI
|
||||
status: "resolved",
|
||||
}),
|
||||
]);
|
||||
expect(graph.limitations).toContainEqual(
|
||||
expect.stringMatching(
|
||||
/\b1 dictionary entry\b.*\b__proto__\b.*\bomitted\b/u,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
graph.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { parseBinary } from "plist";
|
||||
import { z } from "zod";
|
||||
|
||||
import { decodeBinaryPlistValue } from "./BinaryPlistMaterializer.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisInputError,
|
||||
@@ -16,15 +17,10 @@ import {
|
||||
} from "../../domain/apple/plistValue.js";
|
||||
import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js";
|
||||
import {
|
||||
decodeBinaryPlistScalars,
|
||||
retainBinaryPlistUidIdentity,
|
||||
scanBinaryPlistScalars,
|
||||
UnsupportedBinaryPlistUidError,
|
||||
} from "./PlistScalars.js";
|
||||
import {
|
||||
omittedPrototypeKeysLimitation,
|
||||
parseXmlPropertyList,
|
||||
} from "../../domain/propertyListKeys.js";
|
||||
import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
|
||||
import {
|
||||
KeyedArchiveKindError,
|
||||
keyedArchiveInputSchema,
|
||||
@@ -68,8 +64,8 @@ export const decodeKeyedArchiveBytes = (
|
||||
);
|
||||
const binary = hasBinaryPlistSignature(bytes);
|
||||
const xmlText = binary ? undefined : decodeXmlPlistText(bytes);
|
||||
// plist.parseBinary recurses on every object reference and copies shared
|
||||
// containers, so a few hundred bytes can exhaust memory or the stack.
|
||||
// Scalar decoding and source container materialization expand shared references,
|
||||
// so a few hundred bytes can exhaust memory or the stack without preflight.
|
||||
// RangeError reports a resource limit; a reference cycle is malformed.
|
||||
const binaryExpansion = binary
|
||||
? estimateBinaryPlistExpansion(bytes, MAX_DECODE_BYTES, {
|
||||
@@ -91,20 +87,12 @@ export const decodeKeyedArchiveBytes = (
|
||||
const parsed =
|
||||
binaryExpansion !== undefined
|
||||
? {
|
||||
value: retainBinaryPlistUidIdentity(
|
||||
parseBinary(
|
||||
decodeBinaryPlistScalars(
|
||||
bytes,
|
||||
binaryScalarMetadata!,
|
||||
binaryExpansion!,
|
||||
),
|
||||
),
|
||||
value: decodeBinaryPlistValue(
|
||||
bytes,
|
||||
binaryExpansion,
|
||||
binaryScalarMetadata,
|
||||
),
|
||||
numberLiterals: binaryScalarMetadata!.literals,
|
||||
omittedPrototypeKeys: binaryExpansion.binaryPrototypeKeyObserved
|
||||
? null
|
||||
: 0,
|
||||
}
|
||||
: parseXmlPropertyList(xmlText ?? "");
|
||||
const {
|
||||
@@ -135,9 +123,6 @@ export const decodeKeyedArchiveBytes = (
|
||||
limitations: [
|
||||
...graph.limitations,
|
||||
...numbers.limitations(),
|
||||
...(parsed.omittedPrototypeKeys === 0
|
||||
? []
|
||||
: [omittedPrototypeKeysLimitation(parsed.omittedPrototypeKeys)]),
|
||||
...(nonFiniteRealCount === 0
|
||||
? []
|
||||
: [nonFiniteRealLimitation(nonFiniteRealCount)]),
|
||||
|
||||
@@ -7,7 +7,6 @@ import { projectPlistValue } from "../../domain/apple/plistValue.js";
|
||||
import {
|
||||
binaryDateRequiresExactSeconds,
|
||||
decodeBinaryPlistScalars,
|
||||
decodeBinaryPlistUids,
|
||||
scanBinaryPlistScalars,
|
||||
} from "./PlistScalars.js";
|
||||
import {
|
||||
@@ -262,9 +261,10 @@ describe("binary plist UID widths", () => {
|
||||
? []
|
||||
: [{ offset: 8, width: normalizedWidth }],
|
||||
);
|
||||
const compatible = decodeBinaryPlistUids(
|
||||
const compatible = decodeBinaryPlistScalars(
|
||||
bytes,
|
||||
plan.uidOffsetsToNormalize,
|
||||
scanBinaryPlistScalars(bytes, plan),
|
||||
plan,
|
||||
);
|
||||
if (normalizedWidth === undefined) expect(compatible).toBe(bytes);
|
||||
else expect(compatible[8 + width + 1]).toBe(0x80 | (normalizedWidth - 1));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { markPlistDictionary } from "../../domain/apple/plistUid.js";
|
||||
import {
|
||||
createPlistNumberCollector,
|
||||
type PlistNumberLiterals,
|
||||
@@ -33,15 +32,7 @@ const containsSortedOffset = (
|
||||
return false;
|
||||
};
|
||||
|
||||
/** A bounded linked value path retained by the existing binary reference walk. */
|
||||
export interface BinaryPlistUidPath {
|
||||
readonly parent: BinaryPlistUidPath | null;
|
||||
readonly key: string | number;
|
||||
}
|
||||
|
||||
export interface BinaryPlistScalarDecodePlan {
|
||||
/** Present only when an archive reader requested serialized UID identity. */
|
||||
readonly binaryUidPaths?: readonly (BinaryPlistUidPath | null)[];
|
||||
readonly reachableDateOffsets: readonly number[];
|
||||
readonly overlappingDateOffsets: readonly number[];
|
||||
readonly reachableWideIntegerOffsets: readonly number[];
|
||||
@@ -172,21 +163,6 @@ export const binaryPlistCloneOffsetWidth = (
|
||||
);
|
||||
};
|
||||
|
||||
/** Normalize unsupported binary-plist UID widths for the pinned parser. */
|
||||
export const decodeBinaryPlistUids = (
|
||||
bytes: Buffer,
|
||||
uidOffsets: readonly { offset: number; width: number }[],
|
||||
): Buffer => {
|
||||
if (uidOffsets.length === 0) return bytes;
|
||||
const replacements = new Map(
|
||||
uidOffsets.map(({ offset, width }) => [
|
||||
offset,
|
||||
binaryPlistUidRecord(bytes, offset, width),
|
||||
]),
|
||||
);
|
||||
return cloneBinaryPlistObjects(bytes, replacements);
|
||||
};
|
||||
|
||||
const APPLE_EPOCH_MILLISECONDS = 978_307_200_000;
|
||||
|
||||
/** Whether Date's millisecond projection would lose finite, in-range source seconds. */
|
||||
@@ -391,49 +367,6 @@ export const scanBinaryPlistScalars = (
|
||||
return result(incomplete);
|
||||
};
|
||||
|
||||
/** Restore UID identity from validated source locations, never from dictionary shape. */
|
||||
export const retainBinaryPlistUidIdentity = (
|
||||
value: unknown,
|
||||
plan: BinaryPlistScalarDecodePlan,
|
||||
): unknown => {
|
||||
if (plan.binaryUidPaths === undefined) return value;
|
||||
const pending: unknown[] = [value];
|
||||
while (pending.length > 0) {
|
||||
const item = pending.pop();
|
||||
if (Array.isArray(item)) {
|
||||
for (const child of item) pending.push(child);
|
||||
} else if (
|
||||
item !== null &&
|
||||
typeof item === "object" &&
|
||||
!(item instanceof Date) &&
|
||||
!(item instanceof Uint8Array)
|
||||
) {
|
||||
markPlistDictionary(item);
|
||||
for (const child of Object.values(item)) pending.push(child);
|
||||
}
|
||||
}
|
||||
for (let path of plan.binaryUidPaths) {
|
||||
const keys: (string | number)[] = [];
|
||||
while (path !== null) {
|
||||
keys.push(path.key);
|
||||
path = path.parent;
|
||||
}
|
||||
let item: unknown = value;
|
||||
for (let index = keys.length - 1; index >= 0; index -= 1) {
|
||||
if (item === null || typeof item !== "object") {
|
||||
item = undefined;
|
||||
break;
|
||||
}
|
||||
item = Object.getOwnPropertyDescriptor(item, keys[index]!)?.value;
|
||||
}
|
||||
if (item !== null && typeof item === "object") {
|
||||
const uid = Object.getOwnPropertyDescriptor(item, "UID")?.value;
|
||||
if (typeof uid === "number") markPlistDictionary(item, uid);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Preserve source scalars through plist@5's lossy Number and Date parser. */
|
||||
export const decodeBinaryPlistScalars = (
|
||||
bytes: Buffer,
|
||||
@@ -517,6 +450,23 @@ export const decodeBinaryPlistScalars = (
|
||||
return compatible;
|
||||
};
|
||||
|
||||
/** Iterate source offset declarations using the same uint64 interpretation as normalization. */
|
||||
export function* binaryPlistObjectOffsets(bytes: Buffer): Generator<bigint> {
|
||||
const trailer = bytes.length - 32;
|
||||
const width = bytes[trailer + 6] ?? 0;
|
||||
const count = Number(bytes.readBigUInt64BE(trailer + 8));
|
||||
const table = Number(bytes.readBigUInt64BE(trailer + 24));
|
||||
for (let object = 0; object < count; object += 1) {
|
||||
let offset = 0n;
|
||||
for (let byte = 0; byte < width; byte += 1)
|
||||
offset = BigInt.asUintN(
|
||||
64,
|
||||
(offset << 8n) | BigInt(bytes[table + object * width + byte] ?? 0),
|
||||
);
|
||||
yield offset;
|
||||
}
|
||||
}
|
||||
|
||||
const cloneBinaryPlistObjects = (
|
||||
bytes: Buffer,
|
||||
replacements: ReadonlyMap<number, Buffer>,
|
||||
@@ -527,18 +477,7 @@ const cloneBinaryPlistObjects = (
|
||||
const offsetSize = bytes[trailerOffset + 6] ?? 0;
|
||||
const objectCount = Number(bytes.readBigUInt64BE(trailerOffset + 8));
|
||||
const oldTableOffset = Number(bytes.readBigUInt64BE(trailerOffset + 24));
|
||||
const readOffset = (offset: number): bigint => {
|
||||
let value = 0n;
|
||||
for (let index = 0; index < offsetSize; index += 1)
|
||||
value = BigInt.asUintN(
|
||||
64,
|
||||
(value << 8n) | BigInt(bytes[offset + index] ?? 0),
|
||||
);
|
||||
return value;
|
||||
};
|
||||
const offsets = Array.from({ length: objectCount }, (_, index) =>
|
||||
readOffset(oldTableOffset + index * offsetSize),
|
||||
);
|
||||
const offsets = [...binaryPlistObjectOffsets(bytes)];
|
||||
const cloneEntries = [...replacements.entries()];
|
||||
const replacementLengths = cloneEntries.map(([, record]) => record.length);
|
||||
const newOffsetSize =
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
binaryDateRequiresExactSeconds,
|
||||
binaryPlistObjectEnd,
|
||||
binaryPlistUidNormalizationWidth,
|
||||
type BinaryPlistUidPath,
|
||||
} from "./PlistScalars.js";
|
||||
|
||||
/** Preflight the representation created by the pinned plist decoder. */
|
||||
@@ -40,7 +39,7 @@ export const preflightPropertyListDecode = (
|
||||
budget,
|
||||
fail,
|
||||
),
|
||||
binaryPrototypeKeyObserved: false,
|
||||
|
||||
datePrecisionOverlapCount: 0,
|
||||
reachableDateOffsets: [],
|
||||
overlappingDateOffsets: [],
|
||||
@@ -173,11 +172,25 @@ export interface BinaryPlistExpansionOptions {
|
||||
readonly bounds?: "strict" | "decoder";
|
||||
}
|
||||
|
||||
export interface BinaryPlistContainer {
|
||||
readonly object: number;
|
||||
readonly kind: "array" | "dictionary";
|
||||
readonly references: readonly number[];
|
||||
readonly keys?: readonly string[];
|
||||
}
|
||||
|
||||
/** Validated source references, separate from upstream scalar conversion. */
|
||||
export interface BinaryPlistValuePlan {
|
||||
readonly topObject: number;
|
||||
readonly scalars: readonly { object: number; uid: boolean }[];
|
||||
readonly containers: readonly BinaryPlistContainer[];
|
||||
readonly uidIdentity: boolean;
|
||||
}
|
||||
|
||||
/** Preflight size and binary-key observations; XML keys are inspected by decode. */
|
||||
export interface PlistDecodePreflight {
|
||||
readonly binaryUidPaths?: readonly (BinaryPlistUidPath | null)[];
|
||||
readonly binaryValuePlan?: BinaryPlistValuePlan;
|
||||
readonly estimatedBytes: number;
|
||||
readonly binaryPrototypeKeyObserved: boolean;
|
||||
/** Number of expanded references to precision-unknown overlapping dates. */
|
||||
readonly datePrecisionOverlapCount: number;
|
||||
/** Unique reachable date records in the selected value graph. */
|
||||
@@ -204,8 +217,6 @@ export interface PlistDecodePreflight {
|
||||
readonly normalizedReferenceSize: number;
|
||||
}
|
||||
|
||||
const PROTOTYPE_KEY = "__proto__";
|
||||
|
||||
type BinaryObjectSpan = { readonly offset: number; readonly end: number };
|
||||
|
||||
const findOverlappingScalarOffsets = (
|
||||
@@ -236,8 +247,8 @@ const findOverlappingScalarOffsets = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Count every expanded binary-plist reference before calling plist.parseBinary,
|
||||
* which recurses on each reference and copies shared containers. Malformed
|
||||
* Validate and budget every expanded source reference before scalar decoding
|
||||
* and independent container materialization. Malformed
|
||||
* structure throws TypeError; resource and cycle failures use `fail`.
|
||||
*/
|
||||
export const estimateBinaryPlistExpansion = (
|
||||
@@ -298,15 +309,15 @@ export const estimateBinaryPlistExpansion = (
|
||||
);
|
||||
if (normalizedReferenceSize === undefined)
|
||||
throw new TypeError("binary plist object reference count is invalid");
|
||||
// Reserve source metadata and reachable-offset indexes for scalar projection.
|
||||
// Without projection, the traversal retains only structural validation state.
|
||||
const objectBytes = numberProjection || uidProjection ? 320 : 16;
|
||||
// Reserve source container/reference facts, scalar indexing, and the scalar
|
||||
// array/table overhead; projection also retains numeric/UID source metadata.
|
||||
const objectBytes = numberProjection || uidProjection ? 512 : 256;
|
||||
const expandedNodeBytes = numberProjection || uidProjection ? 224 : 96;
|
||||
// Scalar projection may require a same-sized parser-compatible input copy
|
||||
// for wide integers and sub-millisecond dates. Reserve before allocation.
|
||||
// for wide integers and sub-millisecond dates. The scalar array decoder keeps
|
||||
// another input-sized copy; reserve both before allocation.
|
||||
let estimate =
|
||||
bytes.length * (numberProjection ? 3 : 2) + objectCount * objectBytes;
|
||||
let binaryPrototypeKeyObserved = false;
|
||||
bytes.length * (numberProjection ? 4 : 3) + objectCount * objectBytes;
|
||||
if (estimate > maxBytes)
|
||||
throw fail("expansion", `binary plist object table exceeds ${budget}`);
|
||||
|
||||
@@ -316,17 +327,16 @@ export const estimateBinaryPlistExpansion = (
|
||||
depth: number;
|
||||
exit: boolean;
|
||||
dictionaryKey: boolean;
|
||||
path?: BinaryPlistUidPath | null;
|
||||
}> = [
|
||||
{
|
||||
object: topObject,
|
||||
depth: 0,
|
||||
exit: false,
|
||||
dictionaryKey: false,
|
||||
...(uidProjection ? { path: null } : {}),
|
||||
},
|
||||
];
|
||||
const binaryUidPaths: (BinaryPlistUidPath | null)[] = [];
|
||||
const scalarObjects = new Map<number, { object: number; uid: boolean }>();
|
||||
const sourceContainers = new Map<number, BinaryPlistContainer>();
|
||||
const dictionaryKeys = new Map<number, string>();
|
||||
const readDictionaryKey = (object: number): string => {
|
||||
const prior = dictionaryKeys.get(object);
|
||||
@@ -402,6 +412,11 @@ export const estimateBinaryPlistExpansion = (
|
||||
if (numberProjection) reachableObjectOffsets.add(offset);
|
||||
const marker = bytes[offset] ?? 0;
|
||||
const type = marker >> 4;
|
||||
if (type !== 10 && type !== 13)
|
||||
scalarObjects.set(entry.object, {
|
||||
object: entry.object,
|
||||
uid: type === 8,
|
||||
});
|
||||
if (entry.dictionaryKey && type !== 5 && type !== 6)
|
||||
throw new TypeError("binary plist dictionary key is not a string");
|
||||
if (type === 3) {
|
||||
@@ -424,7 +439,6 @@ export const estimateBinaryPlistExpansion = (
|
||||
const uidWidth = (marker & 0x0f) + 1;
|
||||
if (offset + 1 + uidWidth > objectsEnd)
|
||||
throw new TypeError("binary plist UID object is truncated");
|
||||
if (entry.path !== undefined) binaryUidPaths.push(entry.path);
|
||||
const normalizedWidth = binaryPlistUidNormalizationWidth(bytes, offset);
|
||||
if (normalizedWidth !== undefined)
|
||||
uidWidthsToNormalize.set(offset, normalizedWidth);
|
||||
@@ -462,26 +476,6 @@ export const estimateBinaryPlistExpansion = (
|
||||
const byteLength = type === 6 ? size * 2 : size;
|
||||
if (cursor + byteLength > objectsEnd)
|
||||
throw new TypeError("binary plist scalar object is truncated");
|
||||
if (
|
||||
!binaryPrototypeKeyObserved &&
|
||||
entry.dictionaryKey &&
|
||||
(type === 5 || type === 6) &&
|
||||
size === PROTOTYPE_KEY.length
|
||||
) {
|
||||
let matches = true;
|
||||
for (let index = 0; index < PROTOTYPE_KEY.length; index += 1) {
|
||||
const codeUnit =
|
||||
type === 5
|
||||
? (bytes[cursor + index] ?? 0)
|
||||
: ((bytes[cursor + index * 2] ?? 0) << 8) |
|
||||
(bytes[cursor + index * 2 + 1] ?? 0);
|
||||
if (codeUnit !== PROTOTYPE_KEY.charCodeAt(index)) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) binaryPrototypeKeyObserved = true;
|
||||
}
|
||||
estimate += type === 4 ? size + 4 * Math.ceil(size / 3) : byteLength * 2;
|
||||
if (estimate > maxBytes)
|
||||
throw fail(
|
||||
@@ -503,28 +497,24 @@ export const estimateBinaryPlistExpansion = (
|
||||
"expansion",
|
||||
`binary plist reference expansion exceeds ${budget}`,
|
||||
);
|
||||
const parsedReferences: number[] | undefined =
|
||||
referenceSize !== normalizedReferenceSize &&
|
||||
!containersToNormalize.has(offset)
|
||||
? []
|
||||
: undefined;
|
||||
// A duplicate dictionary key exposes only its final value in plist@5.
|
||||
// Keep provenance for that value while still validating every source ref.
|
||||
const valueKeys = new Map<number, string>();
|
||||
if (entry.path !== undefined && type === 13) {
|
||||
const lastKeyIndexes = new Map<string, number>();
|
||||
for (let index = 0; index < size; index += 1) {
|
||||
const reference = readInteger(
|
||||
cursor + index * referenceSize,
|
||||
referenceSize,
|
||||
);
|
||||
if (reference < 0 || reference >= objectCount)
|
||||
throw new TypeError("binary plist reference is invalid");
|
||||
lastKeyIndexes.set(readDictionaryKey(reference), index);
|
||||
}
|
||||
for (const [key, index] of lastKeyIndexes)
|
||||
if (key !== PROTOTYPE_KEY) valueKeys.set(size + index, key);
|
||||
}
|
||||
const references = Array.from({ length: childCount }, (_, index) => {
|
||||
const reference = readInteger(
|
||||
cursor + index * referenceSize,
|
||||
referenceSize,
|
||||
);
|
||||
if (reference < 0 || reference >= objectCount)
|
||||
throw new TypeError("binary plist reference is invalid");
|
||||
return reference;
|
||||
});
|
||||
if (!sourceContainers.has(entry.object))
|
||||
sourceContainers.set(entry.object, {
|
||||
object: entry.object,
|
||||
kind: type === 13 ? "dictionary" : "array",
|
||||
references: type === 13 ? references.slice(size) : references,
|
||||
...(type === 13
|
||||
? { keys: references.slice(0, size).map(readDictionaryKey) }
|
||||
: {}),
|
||||
});
|
||||
active.add(entry.object);
|
||||
pending.push({
|
||||
object: entry.object,
|
||||
@@ -533,33 +523,22 @@ export const estimateBinaryPlistExpansion = (
|
||||
dictionaryKey: false,
|
||||
});
|
||||
for (let index = childCount - 1; index >= 0; index -= 1) {
|
||||
const reference = readInteger(
|
||||
cursor + index * referenceSize,
|
||||
referenceSize,
|
||||
);
|
||||
if (reference < 0 || reference >= objectCount)
|
||||
throw new TypeError("binary plist reference is invalid");
|
||||
if (parsedReferences !== undefined) parsedReferences[index] = reference;
|
||||
const reference = references[index]!;
|
||||
pending.push({
|
||||
object: reference,
|
||||
depth: entry.depth + 1,
|
||||
exit: false,
|
||||
dictionaryKey: type === 13 && index < size,
|
||||
...(entry.path === undefined || (type === 13 && !valueKeys.has(index))
|
||||
? {}
|
||||
: {
|
||||
path: {
|
||||
parent: entry.path,
|
||||
key: type === 13 ? valueKeys.get(index)! : index,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (parsedReferences !== undefined)
|
||||
if (
|
||||
referenceSize !== normalizedReferenceSize &&
|
||||
!containersToNormalize.has(offset)
|
||||
)
|
||||
containersToNormalize.set(offset, {
|
||||
offset,
|
||||
headerLength: cursor - offset,
|
||||
references: parsedReferences,
|
||||
references,
|
||||
});
|
||||
pendingObjectCount += childCount;
|
||||
}
|
||||
@@ -597,7 +576,7 @@ export const estimateBinaryPlistExpansion = (
|
||||
if (cloneLengths.length > 0 || normalizedOffsetSize !== offsetSize) {
|
||||
const cloneBytes = cloneLengths.reduce((sum, length) => sum + length, 0);
|
||||
estimate +=
|
||||
cloneBytes + objectCount * (normalizedOffsetSize - offsetSize);
|
||||
2 * (cloneBytes + objectCount * (normalizedOffsetSize - offsetSize));
|
||||
if (estimate > maxBytes)
|
||||
throw fail(
|
||||
"expansion",
|
||||
@@ -606,8 +585,12 @@ export const estimateBinaryPlistExpansion = (
|
||||
}
|
||||
return {
|
||||
estimatedBytes: estimate,
|
||||
...(uidProjection ? { binaryUidPaths } : {}),
|
||||
binaryPrototypeKeyObserved,
|
||||
binaryValuePlan: {
|
||||
topObject,
|
||||
scalars: [...scalarObjects.values()],
|
||||
containers: [...sourceContainers.values()],
|
||||
uidIdentity: uidProjection,
|
||||
},
|
||||
datePrecisionOverlapCount: 0,
|
||||
reachableDateOffsets: [],
|
||||
overlappingDateOffsets: [],
|
||||
@@ -664,7 +647,8 @@ export const estimateBinaryPlistExpansion = (
|
||||
const normalizedOffsetSize = normalizeOffsetWidth(cloneLengths);
|
||||
if (cloneLengths.length > 0 || normalizedOffsetSize !== offsetSize) {
|
||||
const cloneBytes = cloneLengths.reduce((sum, length) => sum + length, 0);
|
||||
estimate += cloneBytes + objectCount * (normalizedOffsetSize - offsetSize);
|
||||
estimate +=
|
||||
2 * (cloneBytes + objectCount * (normalizedOffsetSize - offsetSize));
|
||||
if (estimate > maxBytes)
|
||||
throw fail(
|
||||
"expansion",
|
||||
@@ -677,8 +661,12 @@ export const estimateBinaryPlistExpansion = (
|
||||
).length;
|
||||
return {
|
||||
estimatedBytes: estimate,
|
||||
...(uidProjection ? { binaryUidPaths } : {}),
|
||||
binaryPrototypeKeyObserved,
|
||||
binaryValuePlan: {
|
||||
topObject,
|
||||
scalars: [...scalarObjects.values()],
|
||||
containers: [...sourceContainers.values()],
|
||||
uidIdentity: uidProjection,
|
||||
},
|
||||
datePrecisionOverlapCount,
|
||||
reachableDateOffsets: [...reachableDateOffsets].sort(
|
||||
(left, right) => left - right,
|
||||
|
||||
@@ -4,10 +4,41 @@ import {
|
||||
buildInterfaceBuilderAnalysis,
|
||||
interfaceBuilderLimitsSchema,
|
||||
} from "./interfaceBuilderGraph.js";
|
||||
import { parseInterfaceBuilderRecords } from "./interfaceBuilderKeyedArchive.js";
|
||||
import {
|
||||
parseInterfaceBuilderRecords,
|
||||
record,
|
||||
} from "./interfaceBuilderKeyedArchive.js";
|
||||
import { markPlistDictionary, plistUidValue } from "./plistUid.js";
|
||||
|
||||
const uid = (value: number) => ({ CF$UID: value });
|
||||
|
||||
it("borrows decoded dictionary identity and keeps authored prototype-named fields", () => {
|
||||
const decoded = JSON.parse('{"CF$UID":2,"__proto__":{"kept":true}}');
|
||||
markPlistDictionary(decoded);
|
||||
expect(record(decoded)).toBe(decoded);
|
||||
expect(plistUidValue(record(decoded))).toBeUndefined();
|
||||
expect(Object.hasOwn(record(decoded), "__proto__")).toBe(true);
|
||||
expect(record(new Date(0))).toEqual({});
|
||||
expect(record(new Uint8Array([1]))).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves ibtool object IDs and attributes that match inherited member names", () => {
|
||||
const object = JSON.parse(
|
||||
'{"class":"NSView","__proto__":{"kept":true},"constructor":"authored"}',
|
||||
);
|
||||
const result = parseInterfaceBuilderRecords({
|
||||
"com.apple.ibtool.document.objects": Object.fromEntries([
|
||||
["__proto__", object],
|
||||
]),
|
||||
"com.apple.ibtool.document.connections": {},
|
||||
});
|
||||
expect(result.objects[0]).toMatchObject({
|
||||
id: "__proto__",
|
||||
attributes: { ["__proto__"]: { kept: true }, constructor: "authored" },
|
||||
});
|
||||
expect(Object.hasOwn(result.objects[0]!.attributes, "__proto__")).toBe(true);
|
||||
});
|
||||
|
||||
const archive = (
|
||||
fields: Record<string, unknown> = {},
|
||||
top: Record<string, unknown> = { root: uid(1) },
|
||||
@@ -35,6 +66,20 @@ const archive = (
|
||||
$top: top,
|
||||
});
|
||||
|
||||
it("preserves keyed object fields while resolving references and removing only $class", () => {
|
||||
const attributes = JSON.parse(
|
||||
'{"__proto__":{"kept":true},"constructor":"authored"}',
|
||||
);
|
||||
const result = parseInterfaceBuilderRecords(archive(attributes));
|
||||
const first = result.objects.find(({ id }) => id === "1");
|
||||
expect(first?.attributes).toMatchObject({
|
||||
["__proto__"]: { kept: true },
|
||||
constructor: "authored",
|
||||
});
|
||||
expect(Object.hasOwn(first!.attributes, "__proto__")).toBe(true);
|
||||
expect(Object.hasOwn(first!.attributes, "$class")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["id", "2"],
|
||||
["id", "record-17"],
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { plistUidValue } from "./plistUid.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import { jsonValueSchema } from "../jsonValue.js";
|
||||
|
||||
const recordSchema = z.record(z.string(), z.unknown());
|
||||
import { jsonObjectSchema, jsonValueSchema } from "../jsonValue.js";
|
||||
|
||||
const objectNode = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
@@ -20,7 +18,7 @@ const objectNode = z.strictObject({
|
||||
]),
|
||||
class_name: z.string().nullable(),
|
||||
name: z.string().min(1),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
attributes: jsonObjectSchema,
|
||||
});
|
||||
|
||||
const connection = z.strictObject({
|
||||
@@ -29,7 +27,7 @@ const connection = z.strictObject({
|
||||
source_id: z.string().min(1),
|
||||
destination_id: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
attributes: jsonObjectSchema,
|
||||
});
|
||||
|
||||
type InterfaceBuilderObject = z.infer<typeof objectNode>;
|
||||
@@ -302,14 +300,14 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
|
||||
if (uid !== undefined) return reference(uid);
|
||||
if (Array.isArray(value)) return value.map(resolve);
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
// `$class` describes the archived object's type. Keep that derived fact
|
||||
// outside its serialized fields so an authored `className` key remains
|
||||
// ordinary application data.
|
||||
if (key !== "$class") output[key] = resolve(child);
|
||||
}
|
||||
return output;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, child]) => {
|
||||
// `$class` describes the archived object's type. Keep that derived fact
|
||||
// outside its serialized fields so an authored `className` key remains
|
||||
// ordinary application data.
|
||||
return key === "$class" ? [] : [[key, resolve(child)]];
|
||||
}),
|
||||
);
|
||||
};
|
||||
const hierarchy = (start: unknown) => {
|
||||
let omittedReferences = 0;
|
||||
@@ -546,9 +544,15 @@ const parseKeyedArchive = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Coerce unknown values to a string-keyed record; non-objects become `{}`. */
|
||||
/** Borrow decoded dictionaries without copying away keys or UID source identity. */
|
||||
export const record = (value: unknown): Record<string, unknown> =>
|
||||
recordSchema.safeParse(value).data ?? {};
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
(Object.getPrototypeOf(value) === Object.prototype ||
|
||||
Object.getPrototypeOf(value) === null)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
/** First non-empty string among the candidates, otherwise null. */
|
||||
export const firstString = (...values: unknown[]): string | null => {
|
||||
|
||||
@@ -9,6 +9,36 @@ const deepValue = (depth: number): unknown => {
|
||||
return value;
|
||||
};
|
||||
|
||||
it("preserves an own root named __proto__ through selection and UID edge projection", () => {
|
||||
const input = {
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$objects: ["$null", { ["__proto__"]: { CF$UID: 0 } }],
|
||||
$top: { ["__proto__"]: { CF$UID: 1 }, ordinary: { CF$UID: 1 } },
|
||||
};
|
||||
const result = projectKeyedArchive(input, {
|
||||
root: "__proto__",
|
||||
offset: 0,
|
||||
limit: 2,
|
||||
});
|
||||
expect(result.roots).toEqual({ ["__proto__"]: { CF$UID: 1 } });
|
||||
expect(result.references).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: null,
|
||||
path: ["__proto__"],
|
||||
target: 1,
|
||||
status: "resolved",
|
||||
}),
|
||||
);
|
||||
expect(result.references).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 1,
|
||||
path: ["__proto__"],
|
||||
target: 0,
|
||||
status: "nil",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("projectKeyedArchive input bound", () => {
|
||||
it("rejects over-deep decoded input at the plist projection limit", () => {
|
||||
expect(() =>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import { bundleResourceBoundResultSchema } from "./bundleResourceRoot.js";
|
||||
import { AnalysisInputError } from "../analysisErrorCore.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../jsonValue.js";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../jsonValue.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { plistUidValue } from "./plistUid.js";
|
||||
import { projectPlistValue } from "./plistValue.js";
|
||||
@@ -43,7 +47,7 @@ export const keyedArchiveResultSchema = bundleResourceBoundResultSchema(
|
||||
archive_path: z.string(),
|
||||
archive_sha256: z.string(),
|
||||
archive_format: z.enum(["binary-plist", "xml-plist"]),
|
||||
roots: z.record(z.string(), jsonValueSchema),
|
||||
roots: jsonObjectSchema,
|
||||
objects: z.array(
|
||||
z.strictObject({
|
||||
id: z.int().nonnegative(),
|
||||
|
||||
@@ -455,3 +455,15 @@ it("counts an authored action with an unresolved receiver in dispatch coverage",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps all own Interface Builder attributes through the graph schema", () => {
|
||||
const attributes = JSON.parse(
|
||||
'{"__proto__":{"recorded":true},"constructor":"authored","prototype":null}',
|
||||
);
|
||||
const projected = nativeInvestigationGraphSchema.parse({
|
||||
...graph,
|
||||
nodes: graph.nodes.map((node) => ({ ...node, attributes })),
|
||||
});
|
||||
expect(projected.nodes[0]?.attributes).toEqual(attributes);
|
||||
expect(Object.hasOwn(projected.nodes[0]!.attributes, "__proto__")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
nativeMetadataLocationSchema,
|
||||
type ObjcSwiftMetadata,
|
||||
} from "./objcSwiftMetadata.js";
|
||||
import { jsonValueSchema } from "../jsonValue.js";
|
||||
import { jsonObjectSchema } from "../jsonValue.js";
|
||||
|
||||
/** Entity kinds shared by decoded Interface Builder archives and native code. */
|
||||
export const nativeInvestigationNodeKindSchema = z.enum([
|
||||
@@ -44,7 +44,7 @@ export const nativeInvestigationNodeSchema = z.strictObject({
|
||||
kind: nativeInvestigationNodeKindSchema,
|
||||
name: z.string().min(1),
|
||||
location: nativeMetadataLocationSchema.nullable(),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
attributes: jsonObjectSchema,
|
||||
evidence: z.array(nativeMetadataEvidenceSchema),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,13 +5,9 @@ import {
|
||||
PlistNestingLimitError,
|
||||
plistNestingLimitMessage,
|
||||
} from "./apple/plistValue.js";
|
||||
import {
|
||||
omitPrototypeKeys,
|
||||
parseXmlPropertyList as parseWithNumbers,
|
||||
} from "./propertyListKeys.js";
|
||||
import { parseXmlPropertyList as parseWithNumbers } from "./propertyListKeys.js";
|
||||
|
||||
// Existing key/omission controls assert those facts exactly; numeric facts
|
||||
// have separate source-type and ambiguity controls below.
|
||||
// Numeric source facts have separate source-type and ambiguity controls.
|
||||
const parseXmlPropertyList = (text: string) => {
|
||||
const { numberLiterals: _numbers, ...parsed } = parseWithNumbers(text);
|
||||
return parsed;
|
||||
@@ -28,15 +24,18 @@ it.each([
|
||||
"<key\n>__proto__</key>",
|
||||
"<key>__proto__<!-- trailing comment --></key>",
|
||||
"<key><![CDATA[__proto__]]><!-- trailing comment --></key>",
|
||||
])("omits and counts a dictionary keyed %s", (key) => {
|
||||
])("preserves an own dictionary member keyed %s", (key) => {
|
||||
const parsed = parseXmlPropertyList(
|
||||
plist(
|
||||
`<dict><key>CFBundleExecutable</key><string>App</string>${key}<dict><key>polluted</key><true/></dict><key>list</key><array><dict>${key}<string>x</string><key>kept</key><integer>1</integer></dict></array></dict>`,
|
||||
),
|
||||
);
|
||||
expect(parsed).toEqual({
|
||||
value: { CFBundleExecutable: "App", list: [{ kept: 1 }] },
|
||||
omittedPrototypeKeys: 2,
|
||||
value: {
|
||||
CFBundleExecutable: "App",
|
||||
["__proto__"]: { polluted: true },
|
||||
list: [{ ["__proto__"]: "x", kept: 1 }],
|
||||
},
|
||||
});
|
||||
expect(Object.getPrototypeOf(parsed.value)).toBe(Object.prototype);
|
||||
});
|
||||
@@ -50,7 +49,6 @@ it("keeps __proto__ text in strings, comments, and similar keys", () => {
|
||||
" __proto__ ": "<key>__proto__</key>",
|
||||
__rea_prototype_key__: "__proto__",
|
||||
},
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,8 +57,7 @@ it("keeps a segmented entity-encoded key that decodes to the placeholder", () =>
|
||||
"<dict><key>__rea<![CDATA[_prototype_key__]]></key><string>kept</string><key>__proto__</key><string>x</string></dict>",
|
||||
);
|
||||
expect(parseXmlPropertyList(text)).toEqual({
|
||||
value: { __rea_prototype_key__: "kept" },
|
||||
omittedPrototypeKeys: 1,
|
||||
value: { __rea_prototype_key__: "kept", ["__proto__"]: "x" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,28 +66,16 @@ it("keeps entity-like text beyond Unicode in comments and CDATA", () => {
|
||||
"<!-- � --><dict><key>raw</key><string><![CDATA[�]]></string><key>__proto__</key><string>x</string></dict>",
|
||||
);
|
||||
expect(parseXmlPropertyList(text)).toEqual({
|
||||
value: { raw: "�" },
|
||||
omittedPrototypeKeys: 1,
|
||||
value: { raw: "�", ["__proto__"]: "x" },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits own __proto__ entries that JSON decoding creates", () => {
|
||||
const decoded: unknown = JSON.parse(
|
||||
'{"__proto__":{"x":1},"a":[{"__proto__":2,"b":3}]}',
|
||||
);
|
||||
expect(omitPrototypeKeys(decoded)).toEqual({
|
||||
value: { a: [{ b: 3 }] },
|
||||
omittedPrototypeKeys: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits complete prototype keys split across character-data nodes", () => {
|
||||
it("preserves last-value-wins for prototype keys split across character-data nodes", () => {
|
||||
const text = plist(
|
||||
"<dict><key>__pro<![CDATA[to__]]></key><string>kept</string><key>__proto__</key><string>omitted</string></dict>",
|
||||
);
|
||||
expect(parseXmlPropertyList(text)).toEqual({
|
||||
value: {},
|
||||
omittedPrototypeKeys: 1,
|
||||
value: { ["__proto__"]: "omitted" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,7 +85,6 @@ it("preserves literal key text, XML characters and encoded line endings across s
|
||||
);
|
||||
expect(parseXmlPropertyList(text)).toEqual({
|
||||
value: { "pre&</key>\r\u0085\u2028]]>post": "a\rb" },
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,7 +95,7 @@ it("does not infer key identity from nested elements", () => {
|
||||
"<dict><key>pre<string>nested</string></key><string>kept</string><key>__proto__<string>nested</string></key><string>omitted</string></dict>",
|
||||
),
|
||||
),
|
||||
).toEqual({ value: { pre: "kept" }, omittedPrototypeKeys: 1 });
|
||||
).toEqual({ value: { pre: "kept", ["__proto__"]: "omitted" } });
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -127,7 +111,6 @@ it.each([
|
||||
.join("");
|
||||
expect(parseXmlPropertyList(plist(`<dict>${entries}</dict>`))).toEqual({
|
||||
value: { ab: 3 },
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,7 +121,6 @@ it("keeps empty keys and alias-looking source keys distinct", () => {
|
||||
"<key>ma<?ignored value?><![CDATA[in]]></key><integer>42</integer>";
|
||||
expect(parseXmlPropertyList(plist(`<dict>${entries}</dict>`))).toEqual({
|
||||
value: { "": 3, __rea_plist_key_0__: "literal", main: 42 },
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,7 +131,6 @@ it.each([
|
||||
const text = plist(body);
|
||||
expect(parseXmlPropertyList(text)).toEqual({
|
||||
value: parse(text),
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,15 +145,13 @@ it("preserves OpenStep dictionary and array autodetection", () => {
|
||||
values: ["one", "two"],
|
||||
__rea_prototype_key__: "kept",
|
||||
},
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
expect(parseXmlPropertyList("(one, two)")).toEqual({
|
||||
value: ["one", "two"],
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts OpenStep __proto__ keys the decoder silently drops", () => {
|
||||
it("preserves OpenStep __proto__ keys and duplicate assignment semantics", () => {
|
||||
// Unquoted, quoted, escape-spelled, and object-valued keys all decode to
|
||||
// __proto__; values, other keys, and `\137` (decoded as "137") do not.
|
||||
const result = parseXmlPropertyList(
|
||||
@@ -182,18 +161,18 @@ it("counts OpenStep __proto__ keys the decoder silently drops", () => {
|
||||
);
|
||||
expect(result).toEqual({
|
||||
value: {
|
||||
nested: {},
|
||||
["__proto__"]: "c",
|
||||
nested: { ["__proto__"]: { polluted: "yes" } },
|
||||
kept: "__proto__",
|
||||
list: ["__proto__"],
|
||||
"137_proto__": "d",
|
||||
},
|
||||
omittedPrototypeKeys: 4,
|
||||
});
|
||||
const nested = (result.value as { nested: object }).nested;
|
||||
expect(Object.getPrototypeOf(nested)).toBe(Object.prototype);
|
||||
});
|
||||
|
||||
it("counts OpenStep compound keys that coerce to __proto__", () => {
|
||||
it("preserves OpenStep compound keys that coerce to __proto__", () => {
|
||||
// `obj[key]` coerces an array key to its comma-joined string.
|
||||
expect(
|
||||
parseXmlPropertyList(
|
||||
@@ -201,8 +180,7 @@ it("counts OpenStep compound keys that coerce to __proto__", () => {
|
||||
"list = (__proto__); }",
|
||||
),
|
||||
).toEqual({
|
||||
value: { "__pro,to__": "c", list: ["__proto__"] },
|
||||
omittedPrototypeKeys: 2,
|
||||
value: { ["__proto__"]: "b", "__pro,to__": "c", list: ["__proto__"] },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -240,9 +218,7 @@ const nestedArrays = (depth: number) =>
|
||||
);
|
||||
|
||||
it("rejects XML nesting beyond the projection limit with the limit diagnostic", () => {
|
||||
// `plist` and the prototype-key traversal recurse once per level, so the
|
||||
// depth check must report the same bounded-resource error as the projection
|
||||
// instead of letting those recursive passes exhaust the call stack.
|
||||
// Source depth must report the shared limit before recursive value decoding.
|
||||
const limit = new PlistNestingLimitError(128);
|
||||
expect(() => parseXmlPropertyList(nestedDictionaries(129))).toThrow(limit);
|
||||
expect(() => parseXmlPropertyList(nestedArrays(129))).toThrow(limit);
|
||||
@@ -283,7 +259,6 @@ it("does not count markup inside comments, CDATA, or the doctype as nesting", ()
|
||||
</dict></plist>`;
|
||||
expect(parseXmlPropertyList(text)).toEqual({
|
||||
value: { a: "<dict><array><dict>", b: ["x"] },
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,7 +290,6 @@ it("ignores complete DOCTYPE internal subsets when checking value depth", () =>
|
||||
]><plist><string>x</string></plist>`;
|
||||
expect(parseXmlPropertyList(text)).toEqual({
|
||||
value: "x",
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,24 +311,25 @@ it.each(["\n", "\r\n", "\r"])(
|
||||
`<dict>${newline}<key>kept key</key><string>a b…c
d</string>${newline}<key${newline}>__proto__<!--tail--></key><string>omitted</string></dict>`,
|
||||
);
|
||||
expect(parseXmlPropertyList(text)).toEqual({
|
||||
value: { "kept\rkey": "a\rb\u0085c\u2028d" },
|
||||
omittedPrototypeKeys: 1,
|
||||
value: { "kept\rkey": "a\rb\u0085c\u2028d", ["__proto__"]: "omitted" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("locates sparse omitted keys across newline-dense source", () => {
|
||||
it("preserves sparse prototype keys across newline-dense source", () => {
|
||||
const lines = "\n".repeat(64 * 1024);
|
||||
const text = plist(
|
||||
`<dict>${lines}<key >__proto__</key><string>omitted</string><key>nested</key><dict>${lines}<key>__proto__<!--tail--></key><string>omitted</string><key>kept</key><true/></dict></dict>`,
|
||||
);
|
||||
expect(parseXmlPropertyList(text)).toEqual({
|
||||
value: { nested: { kept: true } },
|
||||
omittedPrototypeKeys: 2,
|
||||
value: {
|
||||
["__proto__"]: "omitted",
|
||||
nested: { ["__proto__"]: "omitted", kept: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenStep plist nesting preflight", () => {
|
||||
describe("OpenStep plist owned depth", () => {
|
||||
const arrays = (depth: number, leaf = "x") =>
|
||||
"(".repeat(depth) + leaf + ")".repeat(depth);
|
||||
|
||||
@@ -372,7 +347,6 @@ describe("OpenStep plist nesting preflight", () => {
|
||||
(root) => {
|
||||
expect(parseXmlPropertyList(root + arrays(3000))).toEqual({
|
||||
value: root === "{}" ? {} : [],
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -390,7 +364,6 @@ describe("OpenStep plist nesting preflight", () => {
|
||||
])("preserves pinned separator and compound-key semantics for %s", (text) => {
|
||||
expect(parseXmlPropertyList(text)).toEqual({
|
||||
value: parse(text),
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -401,13 +374,12 @@ describe("OpenStep plist nesting preflight", () => {
|
||||
),
|
||||
).toEqual({
|
||||
value: { quote: '"( )', data: new Uint8Array([40, 41]), values: ["x"] },
|
||||
omittedPrototypeKeys: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["\u0085", "\u2028", "\u2029"])(
|
||||
"preserves key identity and omission around inter-element separator %j",
|
||||
"preserves key identity around inter-element separator %j",
|
||||
(separator) => {
|
||||
const parsed = parseXmlPropertyList(
|
||||
plist(
|
||||
@@ -415,8 +387,10 @@ it.each(["\u0085", "\u2028", "\u2029"])(
|
||||
),
|
||||
);
|
||||
expect(parsed).toEqual({
|
||||
value: { [`kept${separator}key`]: `a${separator}b` },
|
||||
omittedPrototypeKeys: 1,
|
||||
value: {
|
||||
[`kept${separator}key`]: `a${separator}b`,
|
||||
["__proto__"]: "omitted",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+15
-240
@@ -1,253 +1,28 @@
|
||||
import { copyPlistUidIdentity } from "./apple/plistUid.js";
|
||||
import { parseOpenStep } from "plist";
|
||||
|
||||
import { decodeXmlPropertyList } from "./propertyListXml.js";
|
||||
import {
|
||||
decodeOpenStepPropertyList,
|
||||
startsOpenStepPropertyList,
|
||||
} from "./propertyListOpenStep.js";
|
||||
import {
|
||||
createPlistNumberCollector,
|
||||
type PlistNumberLiterals,
|
||||
} from "./apple/plistNumbers.js";
|
||||
import { UnknownPlistScalar } from "./apple/unknownPlistScalar.js";
|
||||
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
|
||||
|
||||
import { MAX_PLIST_DEPTH, PlistNestingLimitError } from "./apple/plistValue.js";
|
||||
|
||||
/** The dictionary key REA results cannot carry: validation strips it. */
|
||||
const PROTOTYPE_KEY = "__proto__";
|
||||
|
||||
/** A decoded plist without `__proto__` entries, and how many were omitted. */
|
||||
export interface PropertyListWithoutPrototypeKeys {
|
||||
/** Values and numeric source facts decoded together; OpenStep numbers remain unknown. */
|
||||
export interface ParsedPropertyList {
|
||||
readonly value: unknown;
|
||||
readonly omittedPrototypeKeys: number;
|
||||
}
|
||||
|
||||
/** Value and numeric facts decoded together; OpenStep numeric facts remain unknown. */
|
||||
export interface ParsedPropertyList extends PropertyListWithoutPrototypeKeys {
|
||||
readonly numberLiterals: PlistNumberLiterals;
|
||||
}
|
||||
|
||||
/** Limitation for results whose source plist held `__proto__` entries. */
|
||||
export const omittedPrototypeKeysLimitation = (count: number | null): string =>
|
||||
count === null
|
||||
? "Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key."
|
||||
: `${String(count)} dictionary ${count === 1 ? "entry" : "entries"} keyed __proto__ ${count === 1 ? "was" : "were"} omitted because REA results cannot represent that key.`;
|
||||
|
||||
/** Remove own `__proto__` entries, such as JSON.parse creates, and count them. */
|
||||
export const omitPrototypeKeys = (
|
||||
value: unknown,
|
||||
): PropertyListWithoutPrototypeKeys => {
|
||||
let omitted = 0;
|
||||
const strip = (item: unknown): unknown => {
|
||||
if (Array.isArray(item)) return item.map(strip);
|
||||
if (
|
||||
item === null ||
|
||||
typeof item !== "object" ||
|
||||
item instanceof Date ||
|
||||
item instanceof Uint8Array ||
|
||||
item instanceof UnknownPlistScalar
|
||||
)
|
||||
return item;
|
||||
const projected = Object.fromEntries(
|
||||
Object.entries(item).flatMap(([name, entry]) => {
|
||||
if (name !== PROTOTYPE_KEY) return [[name, strip(entry)]];
|
||||
omitted += 1;
|
||||
return [];
|
||||
}),
|
||||
);
|
||||
copyPlistUidIdentity(item, projected);
|
||||
return projected;
|
||||
};
|
||||
const stripped = strip(value);
|
||||
return { value: stripped, omittedPrototypeKeys: omitted };
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode one validated XML DOM into values and numeric source facts.
|
||||
* Entries keyed `__proto__` are omitted and counted at the same boundary.
|
||||
*/
|
||||
/** Decode XML or the existing OpenStep container form, retaining every dictionary key. */
|
||||
export const parseXmlPropertyList = (
|
||||
text: string,
|
||||
maximumDepth = MAX_PLIST_DEPTH,
|
||||
): ParsedPropertyList => {
|
||||
// Preserve the pinned decoder's existing OpenStep autodetection, also used
|
||||
// by app Info.plist resolution.
|
||||
const first = text[skipOpenStepTrivia(text, 0)];
|
||||
if (first === "{" || first === "(") {
|
||||
// The pinned decoder assigns a `__proto__` key to the object prototype, so
|
||||
// the entry is gone before the decoded value can be counted. Count keys in
|
||||
// the source instead; the rebuild still drops any prototype it replaced.
|
||||
const prototypeKeys = scanOpenStep(text, maximumDepth);
|
||||
return {
|
||||
value: omitPrototypeKeys(parseOpenStep(text)).value,
|
||||
numberLiterals: createPlistNumberCollector().result(true),
|
||||
omittedPrototypeKeys: prototypeKeys,
|
||||
};
|
||||
}
|
||||
const decoded = decodeXmlPropertyList(text, maximumDepth);
|
||||
return {
|
||||
...omitPrototypeKeys(decoded.value),
|
||||
numberLiterals: decoded.numberLiterals,
|
||||
};
|
||||
};
|
||||
|
||||
interface OpenStepContainer {
|
||||
end: string;
|
||||
next: "key" | "equals" | "value" | "separator" | "after-value";
|
||||
/** Source offset of a container that is itself a dictionary key. */
|
||||
keyStart?: number;
|
||||
}
|
||||
|
||||
/** Consume only the punctuation expected between the pinned decoder's values. */
|
||||
const consumeOpenStepSeparator = (
|
||||
parent: OpenStepContainer | undefined,
|
||||
character: string,
|
||||
): "consumed" | "invalid" | "none" => {
|
||||
if (parent?.next === "after-value") {
|
||||
parent.next = "key";
|
||||
return character === ";" ? "consumed" : "none";
|
||||
}
|
||||
if (parent?.next !== "equals" && parent?.next !== "separator") return "none";
|
||||
if (character !== (parent.next === "equals" ? "=" : ",")) return "invalid";
|
||||
parent.next = "value";
|
||||
return "consumed";
|
||||
};
|
||||
|
||||
/** An array may close after a comma; a dictionary still needs its assigned value. */
|
||||
const admitsOpenStepCloser = (
|
||||
parent: OpenStepContainer | undefined,
|
||||
character: string,
|
||||
): boolean =>
|
||||
parent?.end === character &&
|
||||
parent.next !== "equals" &&
|
||||
!(parent.end === "}" && parent.next === "value");
|
||||
|
||||
/**
|
||||
* Bound OpenStep value nesting before the pinned decoder recursively reads it,
|
||||
* and count the dictionary keys that decode to `__proto__`.
|
||||
*/
|
||||
const scanOpenStep = (text: string, maximumDepth: number): number => {
|
||||
let prototypeKeys = 0;
|
||||
const containers: OpenStepContainer[] = [];
|
||||
const completeValue = (): void => {
|
||||
const parent = containers.at(-1);
|
||||
if (parent !== undefined)
|
||||
parent.next =
|
||||
parent.next === "key"
|
||||
? "equals"
|
||||
: parent.end === "}"
|
||||
? "after-value"
|
||||
: "separator";
|
||||
};
|
||||
const assertDepth = (): void => {
|
||||
if (containers.length > maximumDepth)
|
||||
throw new PlistNestingLimitError(maximumDepth);
|
||||
};
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
const afterTrivia = skipOpenStepTrivia(text, cursor);
|
||||
if (afterTrivia !== cursor) {
|
||||
cursor = afterTrivia;
|
||||
continue;
|
||||
}
|
||||
const character = text[cursor] ?? "";
|
||||
const parent = containers.at(-1);
|
||||
// Stop at the first impossible token: only the pinned decoder owns syntax
|
||||
// diagnostics. A later deep suffix cannot change that failure into a limit.
|
||||
if (character === "}" || character === ")") {
|
||||
if (!admitsOpenStepCloser(parent, character)) return prototypeKeys;
|
||||
const closed = containers.pop();
|
||||
if (
|
||||
closed?.keyStart !== undefined &&
|
||||
isOpenStepPrototypeKey(text.slice(closed.keyStart, cursor + 1))
|
||||
)
|
||||
prototypeKeys += 1;
|
||||
// parseOpenStep returns the first root value without reading trailing text.
|
||||
if (containers.length === 0) return prototypeKeys;
|
||||
completeValue();
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
const separator = consumeOpenStepSeparator(parent, character);
|
||||
if (separator === "invalid") return prototypeKeys;
|
||||
if (separator === "consumed") {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (character === "{" || character === "(") {
|
||||
assertDepth();
|
||||
containers.push({
|
||||
end: character === "{" ? "}" : ")",
|
||||
next: character === "{" ? "key" : "value",
|
||||
// A compound key is coerced to a string, e.g. `(__proto__)`.
|
||||
...(parent?.next === "key" ? { keyStart: cursor } : {}),
|
||||
});
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
const end = skipOpenStepScalar(text, cursor);
|
||||
if (end === cursor) return prototypeKeys;
|
||||
if (parent?.next === "key") {
|
||||
if (isOpenStepPrototypeKey(text.slice(cursor, end))) prototypeKeys += 1;
|
||||
} else assertDepth();
|
||||
cursor = end;
|
||||
completeValue();
|
||||
}
|
||||
return prototypeKeys;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether one OpenStep key, scalar or compound, becomes the property name
|
||||
* `__proto__` when the decoder assigns `obj[key]`. Plain strings compare
|
||||
* directly; any other key is decoded by the pinned decoder itself and
|
||||
* coerced to a property name the same way that assignment does.
|
||||
*/
|
||||
const isOpenStepPrototypeKey = (token: string): boolean => {
|
||||
if (!/^["({<]/u.test(token)) return token === PROTOTYPE_KEY;
|
||||
if (token.startsWith('"') && !token.includes("\\"))
|
||||
return token === `"${PROTOTYPE_KEY}"`;
|
||||
try {
|
||||
return String(parseOpenStep(token)) === PROTOTYPE_KEY;
|
||||
} catch {
|
||||
// An unterminated token is the decoder's syntax error to report.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Skip grammar trivia without interpreting delimiter examples inside comments. */
|
||||
const skipOpenStepTrivia = (text: string, cursor: number): number => {
|
||||
while (cursor < text.length) {
|
||||
if (/\s/u.test(text[cursor] ?? "")) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (!text.startsWith("//", cursor) && !text.startsWith("/*", cursor)) break;
|
||||
const line = text[cursor + 1] === "/";
|
||||
const end = text.indexOf(line ? "\n" : "*/", cursor + 2);
|
||||
cursor = end === -1 ? text.length : end + (line ? 1 : 2);
|
||||
}
|
||||
return cursor;
|
||||
};
|
||||
|
||||
/** Read the extent of one opaque scalar; the pinned decoder still owns its value. */
|
||||
const skipOpenStepScalar = (text: string, start: number): number => {
|
||||
let cursor = start;
|
||||
const character = text[cursor];
|
||||
if (character === '"') {
|
||||
cursor += 1;
|
||||
while (cursor < text.length) {
|
||||
const next = text[cursor];
|
||||
cursor += next === "\\" ? 2 : 1;
|
||||
if (next === '"') break;
|
||||
}
|
||||
} else if (character === "<") {
|
||||
const end = text.indexOf(">", cursor + 1);
|
||||
cursor = end === -1 ? text.length : end + 1;
|
||||
} else {
|
||||
// Match the pinned OpenStep decoder's unquoted-string alphabet. Other
|
||||
// syntax remains for the decoder to reject; preflight only owns depth.
|
||||
while (
|
||||
cursor < text.length &&
|
||||
/[a-zA-Z0-9._/$:-]/u.test(text[cursor] ?? "")
|
||||
)
|
||||
cursor += 1;
|
||||
}
|
||||
return cursor;
|
||||
};
|
||||
): ParsedPropertyList =>
|
||||
startsOpenStepPropertyList(text)
|
||||
? {
|
||||
value: decodeOpenStepPropertyList(text, maximumDepth),
|
||||
numberLiterals: createPlistNumberCollector().result(true),
|
||||
}
|
||||
: decodeXmlPropertyList(text, maximumDepth);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { parseOpenStep } from "plist";
|
||||
|
||||
import { plistNestingLimitMessage } from "./apple/plistValue.js";
|
||||
import { decodeOpenStepPropertyList } from "./propertyListOpenStep.js";
|
||||
|
||||
it.each([
|
||||
"{ numeric = (42, -2.5, 0x10, YES); path = /a/b:c$; }",
|
||||
String.raw`{ escapes = "\n\r\t\0\"\\\q\137\U0041"; }`,
|
||||
"{ bytes = (<ffzz0>, <0>, <a b c d>, <>); }",
|
||||
'/* header */ ("//text", "/*text*/", /* before */ value, // after\n)',
|
||||
"{ first = x second = y; third = (a, b,); }",
|
||||
"{ (one, two) = array; { nested = key; } = object; <01ff> = data; }",
|
||||
"{} /* unterminated",
|
||||
"() ((((((((((",
|
||||
])("preserves pinned lexical and container values for %s", (text) => {
|
||||
expect(decodeOpenStepPropertyList(text)).toEqual(parseOpenStep(text));
|
||||
});
|
||||
|
||||
const syntaxFailure = (read: () => unknown): Error => {
|
||||
try {
|
||||
read();
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error) return cause;
|
||||
throw cause;
|
||||
}
|
||||
throw new Error("Expected syntax failure");
|
||||
};
|
||||
|
||||
it.each([
|
||||
"(}",
|
||||
"{)",
|
||||
"(@",
|
||||
"{key @",
|
||||
"(x @",
|
||||
"(x ",
|
||||
"{key ",
|
||||
"{key = x,",
|
||||
'{ key = "unterminated',
|
||||
'{ key = "escape\\',
|
||||
"{ key = <01",
|
||||
"{ /* unterminated",
|
||||
"(x, /* unterminated",
|
||||
"{ { toString = text; } @",
|
||||
"{ { toString = text; } = value; }",
|
||||
])(
|
||||
"preserves pinned syntax diagnosis before later deep input after %s",
|
||||
(prefix) => {
|
||||
for (const text of [prefix, prefix + "(".repeat(130)]) {
|
||||
const pinned = syntaxFailure(() => parseOpenStep(text));
|
||||
const owned = syntaxFailure(() => decodeOpenStepPropertyList(text));
|
||||
expect(owned.constructor).toBe(pinned.constructor);
|
||||
expect(owned.message).toBe(pinned.message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains every compound-coerced dictionary key with last assignment winning", () => {
|
||||
const value = decodeOpenStepPropertyList(
|
||||
'{ (__proto__) = first; (("__proto__")) = last; nested = { __proto__ = { x = y; }; }; constructor = text; }',
|
||||
) as Record<string, unknown>;
|
||||
expect(Object.getOwnPropertyDescriptor(value, "__proto__")?.value).toBe(
|
||||
"last",
|
||||
);
|
||||
expect(Object.getPrototypeOf(value)).toBe(Object.prototype);
|
||||
const nested = value["nested"] as object;
|
||||
expect(Object.getOwnPropertyDescriptor(nested, "__proto__")?.value).toEqual({
|
||||
x: "y",
|
||||
});
|
||||
expect(Object.getPrototypeOf(nested)).toBe(Object.prototype);
|
||||
expect(JSON.stringify(value)).toBe(
|
||||
'{"__proto__":"last","nested":{"__proto__":{"x":"y"}},"constructor":"text"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds compound keys and later siblings at their owning parse", () => {
|
||||
const deep = "(".repeat(30_000) + "x" + ")".repeat(30_000);
|
||||
for (const source of [`((x),${deep})`, `{(key)=${deep};}`, `{${deep}=x;}`])
|
||||
expect(() => decodeOpenStepPropertyList(source)).toThrow(
|
||||
plistNestingLimitMessage(),
|
||||
);
|
||||
// A scalar key identifies its parent's member rather than adding value depth.
|
||||
expect(decodeOpenStepPropertyList("{ key = (); }", 1)).toEqual({ key: [] });
|
||||
expect(() => decodeOpenStepPropertyList("{ (key) = (); }", 1)).toThrow(
|
||||
plistNestingLimitMessage(1),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["(x,", "{ key ="])(
|
||||
"retains incomplete-value syntax for %s",
|
||||
(text) => {
|
||||
expect(syntaxFailure(() => decodeOpenStepPropertyList(text)).message).toBe(
|
||||
syntaxFailure(() => parseOpenStep(text)).message,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,135 @@
|
||||
import { parseOpenStep } from "plist";
|
||||
|
||||
import { MAX_PLIST_DEPTH, PlistNestingLimitError } from "./apple/plistValue.js";
|
||||
import { markPlistDictionary } from "./apple/plistUid.js";
|
||||
|
||||
/** Locate the first value for the XML owner's existing OpenStep autodetection. */
|
||||
export const startsOpenStepPropertyList = (text: string): boolean => {
|
||||
const cursor = skipTrivia(text, 0, false);
|
||||
return text[cursor] === "{" || text[cursor] === "(";
|
||||
};
|
||||
|
||||
const skipTrivia = (text: string, start: number, strict = true): number => {
|
||||
let cursor = start;
|
||||
while (cursor < text.length) {
|
||||
if (/\s/u.test(text[cursor]!)) cursor += 1;
|
||||
else if (text.startsWith("//", cursor)) {
|
||||
const end = text.indexOf("\n", cursor + 2);
|
||||
cursor = end < 0 ? text.length : end + 1;
|
||||
} else if (text.startsWith("/*", cursor)) {
|
||||
const end = text.indexOf("*/", cursor + 2);
|
||||
if (end < 0) {
|
||||
if (strict) throw new Error("Unterminated block comment");
|
||||
return text.length;
|
||||
}
|
||||
cursor = end + 2;
|
||||
} else break;
|
||||
}
|
||||
return cursor;
|
||||
};
|
||||
|
||||
/**
|
||||
* Own OpenStep containers, source depth and dictionary members together.
|
||||
* The pinned scalar decoder retains its quoted escape and hexadecimal semantics.
|
||||
*/
|
||||
export const decodeOpenStepPropertyList = (
|
||||
text: string,
|
||||
maximumDepth = MAX_PLIST_DEPTH,
|
||||
): unknown => {
|
||||
let cursor = 0;
|
||||
const trivia = () => {
|
||||
cursor = skipTrivia(text, cursor);
|
||||
};
|
||||
const assertDepth = (depth: number) => {
|
||||
if (depth > maximumDepth) throw new PlistNestingLimitError(maximumDepth);
|
||||
};
|
||||
const value = (depth: number, dictionaryKey = false): unknown => {
|
||||
trivia();
|
||||
if (cursor >= text.length) throw new Error("Unexpected end of input");
|
||||
const character = text[cursor]!;
|
||||
if (character === "{") {
|
||||
assertDepth(depth);
|
||||
cursor += 1;
|
||||
const dictionary: Record<string, unknown> = {};
|
||||
markPlistDictionary(dictionary);
|
||||
while (true) {
|
||||
trivia();
|
||||
if (cursor >= text.length) throw new Error("Unterminated dictionary");
|
||||
if (text[cursor] === "}") {
|
||||
cursor += 1;
|
||||
return dictionary;
|
||||
}
|
||||
const key = value(depth + 1, true);
|
||||
trivia();
|
||||
if (text[cursor] !== "=")
|
||||
throw new Error(
|
||||
`Expected '=' after key "${String(key)}" at position ${cursor}`,
|
||||
);
|
||||
cursor += 1;
|
||||
const entry = value(depth + 1);
|
||||
Object.defineProperty(dictionary, String(key), {
|
||||
value: entry,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
trivia();
|
||||
if (text[cursor] === ";") cursor += 1;
|
||||
}
|
||||
}
|
||||
if (character === "(") {
|
||||
assertDepth(depth);
|
||||
cursor += 1;
|
||||
const array: unknown[] = [];
|
||||
trivia();
|
||||
if (text[cursor] === ")") {
|
||||
cursor += 1;
|
||||
return array;
|
||||
}
|
||||
while (true) {
|
||||
array.push(value(depth + 1));
|
||||
trivia();
|
||||
if (cursor >= text.length) throw new Error("Unterminated array");
|
||||
if (text[cursor] === ")") {
|
||||
cursor += 1;
|
||||
return array;
|
||||
}
|
||||
if (text[cursor] !== ",")
|
||||
throw new Error(`Expected ',' or ')' in array at position ${cursor}`);
|
||||
cursor += 1;
|
||||
trivia();
|
||||
if (text[cursor] === ")") {
|
||||
cursor += 1;
|
||||
return array;
|
||||
}
|
||||
}
|
||||
}
|
||||
const start = cursor;
|
||||
if (character === '"') {
|
||||
cursor += 1;
|
||||
while (cursor < text.length) {
|
||||
const next = text[cursor]!;
|
||||
cursor += next === "\\" ? 2 : 1;
|
||||
if (next === '"') break;
|
||||
}
|
||||
} else if (character === "<") {
|
||||
const end = text.indexOf(">", cursor + 1);
|
||||
cursor = end < 0 ? text.length : end + 1;
|
||||
} else {
|
||||
while (cursor < text.length && /[a-zA-Z0-9._/$:-]/u.test(text[cursor]!))
|
||||
cursor += 1;
|
||||
if (cursor === start)
|
||||
throw new Error(
|
||||
`Unexpected character '${character}' at position ${cursor}`,
|
||||
);
|
||||
}
|
||||
// Scalar dictionary keys do not add source-value nesting; compound keys do.
|
||||
if (!dictionaryKey) assertDepth(depth);
|
||||
const token = text.slice(start, cursor);
|
||||
return character === '"' || character === "<"
|
||||
? parseOpenStep(token)
|
||||
: token;
|
||||
};
|
||||
// The pinned format reads one root and leaves all trailing source untouched.
|
||||
return value(0);
|
||||
};
|
||||
@@ -11,15 +11,17 @@ it("preserves array values and comment-like string content after leading trivia"
|
||||
expect(parsed.numberLiterals.incomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("retains prototype-key omission through commented OpenStep dispatch", () => {
|
||||
it("preserves own prototype keys through commented OpenStep dispatch", () => {
|
||||
const parsed = parseXmlPropertyList(
|
||||
'/* header */ { __proto__ = { secret = value; }; kept = "/*literal*/"; }',
|
||||
);
|
||||
expect(parsed.value).toEqual({ kept: "/*literal*/" });
|
||||
expect(parsed.omittedPrototypeKeys).toBe(1);
|
||||
expect(parsed.value).toEqual({
|
||||
["__proto__"]: { secret: "value" },
|
||||
kept: "/*literal*/",
|
||||
});
|
||||
});
|
||||
|
||||
it("retains the depth preflight after a leading comment", () => {
|
||||
it("retains the source depth limit after a leading comment", () => {
|
||||
expect(() =>
|
||||
parseXmlPropertyList(`// header\n${"(".repeat(129)}x${")".repeat(129)}`),
|
||||
).toThrow(plistNestingLimitMessage());
|
||||
|
||||
@@ -185,7 +185,7 @@ it("decodes a signed integer zero as zero and a negative-zero real with its sign
|
||||
]);
|
||||
});
|
||||
|
||||
it("interprets failures in omitted entries before reporting partial evidence", () => {
|
||||
it("validates values under every dictionary key", () => {
|
||||
expect(() =>
|
||||
parseXmlPropertyList(
|
||||
"<plist><dict><key>__proto__</key><unsupported/></dict></plist>",
|
||||
@@ -335,3 +335,16 @@ it("expands real references outside CDATA without changing scalar source policy"
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["__proto__", "constructor", "ordinary"])(
|
||||
"retains %s key elements used as scalar values",
|
||||
(value) => {
|
||||
expect(
|
||||
parseXmlPropertyList(`<plist><key>${value}</key></plist>`).value,
|
||||
).toBe(value);
|
||||
expect(
|
||||
parseXmlPropertyList(`<plist><array><key>${value}</key></array></plist>`)
|
||||
.value,
|
||||
).toEqual([value]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -92,7 +92,7 @@ export const decodeXmlPropertyList = (
|
||||
}
|
||||
if (key !== undefined)
|
||||
throw new TypeError("Missing value for key while parsing <dict/>");
|
||||
// Define own properties safely, including __proto__; the caller owns omission accounting.
|
||||
// Dictionary names are data, including __proto__.
|
||||
const value = Object.fromEntries(entries);
|
||||
// XML archives use Foundation's CF$UID convention; a UID key is data.
|
||||
const keys = Object.keys(value);
|
||||
@@ -110,8 +110,6 @@ export const decodeXmlPropertyList = (
|
||||
// The pinned decoder also accepted key elements as scalars. Keep their
|
||||
// first-child interpretation separate from dictionary key identities.
|
||||
const value = node.firstChild === null ? "" : node.firstChild.nodeValue;
|
||||
if (value === "__proto__")
|
||||
throw new TypeError("__proto__ is not a scalar plist key");
|
||||
return value;
|
||||
}
|
||||
case "string":
|
||||
|
||||
@@ -84,10 +84,7 @@ import {
|
||||
} from "../domain/apple/plistValue.js";
|
||||
import { createPlistNumberProjection } from "../domain/apple/plistNumbers.js";
|
||||
import type { NativeUiObservationResult } from "../domain/native/nativeUiObservation.js";
|
||||
import {
|
||||
omittedPrototypeKeysLimitation,
|
||||
parseXmlPropertyList,
|
||||
} from "../domain/propertyListKeys.js";
|
||||
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
|
||||
import {
|
||||
demangleSwiftSchema,
|
||||
inspectPlistSchema,
|
||||
@@ -808,11 +805,6 @@ class NativeMacOSClient implements AnalysisClient {
|
||||
...parsed.limitations,
|
||||
...entitlementValue.limitations.map((note) => `Entitlements: ${note}`),
|
||||
"Inspected a digest-verified Mach-O byte snapshot; filesystem-bound signing context and trust validation are not examined.",
|
||||
...(entitlementValue.omittedPrototypeKeys === 0
|
||||
? []
|
||||
: [
|
||||
`Entitlements: ${omittedPrototypeKeysLimitation(entitlementValue.omittedPrototypeKeys)}`,
|
||||
]),
|
||||
];
|
||||
const architectures = codeSignArchitectures(parsed.format, this.target);
|
||||
if (architectures.length > 1)
|
||||
@@ -1491,13 +1483,11 @@ const parseEntitlements = (
|
||||
output: string,
|
||||
): {
|
||||
readonly value: JsonValue;
|
||||
readonly omittedPrototypeKeys: number;
|
||||
readonly limitations: readonly string[];
|
||||
} => {
|
||||
const start = output.indexOf("<?xml");
|
||||
const end = output.lastIndexOf("</plist>");
|
||||
if (start < 0 || end < start)
|
||||
return { value: null, omittedPrototypeKeys: 0, limitations: [] };
|
||||
if (start < 0 || end < start) return { value: null, limitations: [] };
|
||||
const decoded = parseXmlPropertyList(
|
||||
output.slice(start, end + "</plist>".length),
|
||||
);
|
||||
@@ -1506,7 +1496,6 @@ const parseEntitlements = (
|
||||
const value = numbers.project(projected.value);
|
||||
return {
|
||||
value,
|
||||
omittedPrototypeKeys: decoded.omittedPrototypeKeys,
|
||||
limitations: [
|
||||
...numbers.limitations(),
|
||||
...(projected.nonFiniteRealCount === 0
|
||||
|
||||
@@ -530,15 +530,16 @@ it("rejects a truncated binary plist source", () => {
|
||||
|
||||
it("retains complete segmented XML keys in native plist fallback evidence", () => {
|
||||
const parsed = parsePlistXml(
|
||||
"<plist><dict><key>user<![CDATA[ID]]></key><integer>42</integer><key>__pro<![CDATA[to__]]></key><string>omitted</string><key>Blob</key><data>AA==</data></dict></plist>",
|
||||
"<plist><dict><key>user<![CDATA[ID]]></key><integer>42</integer><key>__pro<![CDATA[to__]]></key><string>retained</string><key>Blob</key><data>AA==</data></dict></plist>",
|
||||
);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
expect(parsed.value.value).toEqual({
|
||||
userID: 42,
|
||||
["__proto__"]: "retained",
|
||||
Blob: { $plist_type: "data", base64: "AA==" },
|
||||
});
|
||||
expect(parsed.value.limitations).toContain(
|
||||
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
|
||||
expect(parsed.value.limitations).not.toContainEqual(
|
||||
expect.stringContaining("omitted"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -720,18 +721,24 @@ it("uses only surviving duplicate JSON entries for number types and limitations"
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts numeric source facts before omitting prototype dictionary entries", () => {
|
||||
it("preserves numeric source facts inside prototype-named dictionary entries", () => {
|
||||
const attempt = readPlistJson(
|
||||
'{"__proto__":{"integer":9007199254740993,"zero":-0.0,"__proto__":7},"kept":42}',
|
||||
);
|
||||
expect(attempt.needsNumberTypes).toBe(true);
|
||||
const parsed = attempt.project();
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
expect(parsed.value.value).toEqual({ kept: 42 });
|
||||
expect(parsed.value.value).toEqual({
|
||||
["__proto__"]: {
|
||||
integer: 9007199254740992,
|
||||
zero: { $plist_type: "real", value: "-0" },
|
||||
["__proto__"]: 7,
|
||||
},
|
||||
kept: 42,
|
||||
});
|
||||
expect(parsed.value.limitations).toEqual([
|
||||
expect.stringContaining("1 negative-zero real value(s)"),
|
||||
expect.stringContaining("1 number(s)"),
|
||||
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -842,3 +849,31 @@ it("keeps CFPropertyList's non-finite real spellings in XML evidence", () => {
|
||||
expect.stringContaining("1 XML scalar value(s) could not be established"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains nested own dictionary names consistently in native JSON, XML and binary projections", () => {
|
||||
const value = Object.fromEntries([
|
||||
[
|
||||
"__proto__",
|
||||
Object.fromEntries([
|
||||
["__proto__", "nested"],
|
||||
["constructor", "authored"],
|
||||
]),
|
||||
],
|
||||
["CFBundleExecutable", "Selected"],
|
||||
]);
|
||||
const xml =
|
||||
"<plist><dict><key>__proto__</key><dict><key>__proto__</key><string>nested</string><key>constructor</key><string>authored</string></dict><key>CFBundleExecutable</key><string>Selected</string></dict></plist>";
|
||||
for (const parsed of [
|
||||
readPlistJson(JSON.stringify(value)).project(),
|
||||
parsePlistXml(xml),
|
||||
parsePlistBinary(Buffer.from(buildBinary(value))),
|
||||
]) {
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
expect(JSON.stringify(parsed.value.value)).toBe(JSON.stringify(value));
|
||||
expect(jsonValueSchema.parse(parsed.value.value)).toEqual(value);
|
||||
expect(parsed.value.bundle.executable).toBe("Selected");
|
||||
expect(parsed.value.limitations).not.toContainEqual(
|
||||
expect.stringContaining("omitted"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
+11
-30
@@ -1,5 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { parseBinary } from "plist";
|
||||
import { decodeBinaryPlistValue } from "../../artifacts/apple/BinaryPlistMaterializer.js";
|
||||
|
||||
import {
|
||||
AnalysisOutputError,
|
||||
@@ -18,11 +17,7 @@ import {
|
||||
type ProjectedPlistValue,
|
||||
PlistNestingLimitError,
|
||||
} from "../../domain/apple/plistValue.js";
|
||||
import {
|
||||
omitPrototypeKeys,
|
||||
omittedPrototypeKeysLimitation,
|
||||
parseXmlPropertyList,
|
||||
} from "../../domain/propertyListKeys.js";
|
||||
import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
|
||||
import {
|
||||
@@ -31,7 +26,6 @@ import {
|
||||
type PlistNumberLiterals,
|
||||
} from "../../domain/apple/plistNumbers.js";
|
||||
import {
|
||||
decodeBinaryPlistScalars,
|
||||
scanBinaryPlistScalars,
|
||||
UnsupportedBinaryPlistUidError,
|
||||
} from "../../artifacts/apple/PlistScalars.js";
|
||||
@@ -63,8 +57,6 @@ const mebibytes = (bytes: number): string =>
|
||||
/** Recovery name for the fixed source-size ceiling. */
|
||||
export const NATIVE_PLIST_INPUT_ALLOWANCE = `the fixed ${mebibytes(MAX_NATIVE_PLIST_BYTES)} (${String(MAX_NATIVE_PLIST_BYTES)}-byte) native plist input allowance (no runtime override)`;
|
||||
|
||||
const plistObject = z.record(z.string(), z.unknown());
|
||||
|
||||
/** Stable bundle metadata projected from one plist value. */
|
||||
export interface PlistBundleMetadata {
|
||||
readonly identifier: string | null;
|
||||
@@ -234,7 +226,7 @@ const projectPlistJsonAttempt = (
|
||||
);
|
||||
return item;
|
||||
};
|
||||
let decoded: ReturnType<typeof omitPrototypeKeys>;
|
||||
let value: unknown;
|
||||
try {
|
||||
// Preserve XML-before-JSON failure precedence during final projection.
|
||||
literals =
|
||||
@@ -243,12 +235,10 @@ const projectPlistJsonAttempt = (
|
||||
: parseXmlPropertyList(xmlConversion, MAX_NATIVE_PLIST_NESTING_DEPTH)
|
||||
.numberLiterals;
|
||||
if (!attempt.ok) return err(plistOutputError(attempt.error));
|
||||
// Numeric limitations also account for values under omitted source keys.
|
||||
decoded = omitPrototypeKeys(project(attempt.value, 0));
|
||||
value = project(attempt.value, 0);
|
||||
} catch (cause: unknown) {
|
||||
return err(plistOutputError(cause));
|
||||
}
|
||||
const { value } = decoded;
|
||||
return ok({
|
||||
value,
|
||||
bundle: projectPlistBundle(value),
|
||||
@@ -258,7 +248,6 @@ const projectPlistJsonAttempt = (
|
||||
? [incompletePlistNumberLimitation]
|
||||
: []),
|
||||
...numberLimitations(exactIntegerCount, ambiguousNumberCount),
|
||||
...prototypeKeyLimitations(decoded.omittedPrototypeKeys),
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -323,14 +312,12 @@ const projectPlistXml = (
|
||||
failureReason?: string,
|
||||
): Result<ParsedPlist, PlistParseError> => {
|
||||
let projected: ProjectedPlistValue;
|
||||
let omittedPrototypeKeys: number;
|
||||
let literals: PlistNumberLiterals;
|
||||
try {
|
||||
const decoded = parseXmlPropertyList(
|
||||
output,
|
||||
MAX_NATIVE_PLIST_NESTING_DEPTH,
|
||||
);
|
||||
omittedPrototypeKeys = decoded.omittedPrototypeKeys;
|
||||
literals = decoded.numberLiterals;
|
||||
projected = projectPlistValue(
|
||||
decoded.value,
|
||||
@@ -367,7 +354,6 @@ const projectPlistXml = (
|
||||
...projectedScalarLimitations(projected),
|
||||
...(literals.incomplete ? [incompletePlistNumberLimitation] : []),
|
||||
...numberLimitations(exactIntegerCount, ambiguousNumberCount),
|
||||
...prototypeKeyLimitations(omittedPrototypeKeys),
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -400,9 +386,7 @@ export const parsePlistBinary = (
|
||||
},
|
||||
);
|
||||
const scalarMetadata = scanBinaryPlistScalars(bytes, expansion);
|
||||
const decoded = parseBinary(
|
||||
decodeBinaryPlistScalars(bytes, scalarMetadata, expansion),
|
||||
);
|
||||
const decoded = decodeBinaryPlistValue(bytes, expansion, scalarMetadata);
|
||||
const projected = projectPlistValue(
|
||||
decoded,
|
||||
undefined,
|
||||
@@ -423,9 +407,6 @@ export const parsePlistBinary = (
|
||||
),
|
||||
]),
|
||||
...numbers.limitations(),
|
||||
...(expansion.binaryPrototypeKeyObserved
|
||||
? [omittedPrototypeKeysLimitation(null)]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
@@ -462,9 +443,6 @@ export const parsePlistBinary = (
|
||||
}
|
||||
};
|
||||
|
||||
const prototypeKeyLimitations = (count: number): string[] =>
|
||||
count === 0 ? [] : [omittedPrototypeKeysLimitation(count)];
|
||||
|
||||
const projectedScalarLimitations = (
|
||||
projected: ProjectedPlistValue,
|
||||
): string[] => [
|
||||
@@ -517,10 +495,13 @@ const mapJsonNumbers = (
|
||||
};
|
||||
|
||||
const projectPlistBundle = (value: unknown): PlistBundleMetadata => {
|
||||
const object = plistObject.safeParse(value);
|
||||
const object =
|
||||
value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
const field = (name: string): string | null => {
|
||||
if (!object.success) return null;
|
||||
const candidate = object.data[name];
|
||||
if (object === undefined || !Object.hasOwn(object, name)) return null;
|
||||
const candidate = object[name];
|
||||
return typeof candidate === "string" ? candidate : null;
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -96,61 +96,70 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
const keyedNibIdentityFixture = (format: "xml" | "binary"): Buffer => {
|
||||
// Source-derived keyed-nib records, not output from an Apple compiler.
|
||||
const uid = (value: number) =>
|
||||
format === "binary" ? { UID: value } : { CF$UID: value };
|
||||
const source = {
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: [
|
||||
"$null",
|
||||
{
|
||||
$class: uid(4),
|
||||
id: "2",
|
||||
archiveUID: 2,
|
||||
objectID: uid(8),
|
||||
"object-id": uid(9),
|
||||
title: "Root",
|
||||
proto_key: { constructor: "authored", kept: true },
|
||||
NSSubviews: [uid(2)],
|
||||
},
|
||||
{
|
||||
$class: uid(4),
|
||||
objectID: uid(8),
|
||||
"object-id": uid(9),
|
||||
title: "Child",
|
||||
},
|
||||
{
|
||||
$class: uid(5),
|
||||
objectID: uid(8),
|
||||
UISource: uid(2),
|
||||
UIDestination: uid(1),
|
||||
UILabel: "parent",
|
||||
},
|
||||
{
|
||||
$classname: "ArchiveProbeView",
|
||||
$classes: ["ArchiveProbeView", "UIView", "NSObject"],
|
||||
},
|
||||
{
|
||||
$classname: "UIRuntimeOutletConnection",
|
||||
$classes: [
|
||||
"UIRuntimeOutletConnection",
|
||||
"UIRuntimeConnection",
|
||||
"NSObject",
|
||||
],
|
||||
},
|
||||
{ $class: uid(7), "NS.objects": [uid(1)] },
|
||||
{ $classname: "NSArray", $classes: ["NSArray", "NSObject"] },
|
||||
"2",
|
||||
"shared:hierarchy:authored",
|
||||
],
|
||||
$top: { UINibTopLevelObjectsKey: uid(6) },
|
||||
};
|
||||
const bytes = Buffer.from(
|
||||
format === "binary" ? buildBinary(source) : build(source),
|
||||
);
|
||||
const keyOffset = bytes.indexOf("proto_key");
|
||||
expect(keyOffset).toBeGreaterThanOrEqual(0);
|
||||
Buffer.from("__proto__").copy(bytes, keyOffset);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
cliTest.for(["xml", "binary"] as const)(
|
||||
"preserves UID identity despite colliding authored IDs in %s through CLI and MCP",
|
||||
async (format, { cli }) => {
|
||||
// Source-derived keyed-nib records, not output from an Apple compiler.
|
||||
const uid = (value: number) =>
|
||||
format === "binary" ? { UID: value } : { CF$UID: value };
|
||||
const source = {
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: [
|
||||
"$null",
|
||||
{
|
||||
$class: uid(4),
|
||||
id: "2",
|
||||
archiveUID: 2,
|
||||
objectID: uid(8),
|
||||
"object-id": uid(9),
|
||||
title: "Root",
|
||||
NSSubviews: [uid(2)],
|
||||
},
|
||||
{
|
||||
$class: uid(4),
|
||||
objectID: uid(8),
|
||||
"object-id": uid(9),
|
||||
title: "Child",
|
||||
},
|
||||
{
|
||||
$class: uid(5),
|
||||
objectID: uid(8),
|
||||
UISource: uid(2),
|
||||
UIDestination: uid(1),
|
||||
UILabel: "parent",
|
||||
},
|
||||
{
|
||||
$classname: "ArchiveProbeView",
|
||||
$classes: ["ArchiveProbeView", "UIView", "NSObject"],
|
||||
},
|
||||
{
|
||||
$classname: "UIRuntimeOutletConnection",
|
||||
$classes: [
|
||||
"UIRuntimeOutletConnection",
|
||||
"UIRuntimeConnection",
|
||||
"NSObject",
|
||||
],
|
||||
},
|
||||
{ $class: uid(7), "NS.objects": [uid(1)] },
|
||||
{ $classname: "NSArray", $classes: ["NSArray", "NSObject"] },
|
||||
"2",
|
||||
"shared:hierarchy:authored",
|
||||
],
|
||||
$top: { UINibTopLevelObjectsKey: uid(6) },
|
||||
};
|
||||
const bytes = Buffer.from(
|
||||
format === "binary" ? buildBinary(source) : build(source),
|
||||
);
|
||||
const bytes = keyedNibIdentityFixture(format);
|
||||
const app = await writeUIKitApp("rea-keyed-nib-hierarchy-", bytes);
|
||||
const path = join(app, "Contents/Resources/Main.nib");
|
||||
const output = await cli.run({
|
||||
@@ -180,8 +189,10 @@ cliTest.for(["xml", "binary"] as const)(
|
||||
objectID: "2",
|
||||
"object-id": "shared:hierarchy:authored",
|
||||
title: "Root",
|
||||
["__proto__"]: { constructor: "authored", kept: true },
|
||||
},
|
||||
});
|
||||
expect(Object.hasOwn(root!.attributes, "__proto__")).toBe(true);
|
||||
const child = report.graph.nodes.find(
|
||||
({ attributes }) => attributes.interface_builder_object_id === "2",
|
||||
);
|
||||
|
||||
@@ -171,7 +171,13 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
separators: "a\u0085b\u2028c\u2029d",
|
||||
payload: { $plist_type: "data", base64: "AAEC" },
|
||||
},
|
||||
provenance: [{ tool: "file" }],
|
||||
provenance: [
|
||||
{ tool: "file" },
|
||||
{
|
||||
tool: "plutil",
|
||||
command: ["/usr/bin/plutil", "-lint", "--", "$PLIST"],
|
||||
},
|
||||
],
|
||||
limitations: [],
|
||||
});
|
||||
expect(Buffer.compare(await readFile(binaryPath), binaryBytes)).toBe(0);
|
||||
|
||||
@@ -7,9 +7,6 @@ import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const OMITTED =
|
||||
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.";
|
||||
|
||||
describe.skipIf(process.platform !== "darwin")(
|
||||
"native plist CLI with a __proto__ key",
|
||||
() => {
|
||||
@@ -17,7 +14,7 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
["JSON-expressible", ""],
|
||||
["data-bearing", "<key>Blob</key><data>AAEC</data>"],
|
||||
] as const)(
|
||||
"inspects a $0 plist and reports the omitted key",
|
||||
"inspects a $0 plist and retains the prototype-named key",
|
||||
async ([, extra], { cli }) => {
|
||||
const directory = await createTestTempDirectory("rea-plist-proto-");
|
||||
const app = join(directory, "Proto.app");
|
||||
@@ -41,12 +38,19 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
normalized_result: {
|
||||
value: { CFBundleExecutable: "App" },
|
||||
value: {
|
||||
CFBundleExecutable: "App",
|
||||
["__proto__"]: { polluted: true },
|
||||
},
|
||||
bundle: { executable: "App" },
|
||||
limitations: expect.arrayContaining([OMITTED]),
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result.json)).not.toContain("polluted");
|
||||
expect(JSON.stringify(result.json)).toContain(
|
||||
'"__proto__":{"polluted":true}',
|
||||
);
|
||||
expect(JSON.stringify(result.json)).not.toContain(
|
||||
"keyed __proto__ was omitted",
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -296,9 +296,9 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
},
|
||||
);
|
||||
cliTest(
|
||||
"omits a valid prototype key after native validation",
|
||||
"retains a valid prototype key after native validation",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-plist-valid-omission-");
|
||||
const root = await createTestTempDirectory("rea-plist-valid-key-");
|
||||
const path = join(root, "chosen.plist");
|
||||
const source =
|
||||
"<plist><dict><key>__proto__</key><integer>42</integer><key>kept</key><string>value</string></dict></plist>";
|
||||
@@ -310,7 +310,7 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
normalized_result: {
|
||||
value: { kept: "value" },
|
||||
value: { ["__proto__"]: 42, kept: "value" },
|
||||
provenance: [
|
||||
expect.objectContaining({ tool: "file" }),
|
||||
expect.objectContaining({
|
||||
@@ -318,13 +318,12 @@ describe.skipIf(process.platform !== "darwin")(
|
||||
command: ["/usr/bin/plutil", "-lint", "--", "$PLIST"],
|
||||
}),
|
||||
],
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"1 dictionary entry keyed __proto__ was omitted",
|
||||
),
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result.json)).toContain('"__proto__":42');
|
||||
expect(JSON.stringify(result.json)).not.toContain(
|
||||
"keyed __proto__ was omitted",
|
||||
);
|
||||
expect(await readFile(path, "utf8")).toBe(source);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1306,8 +1306,8 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
describe("binary Interface Builder archive omissions", () => {
|
||||
it("reports omitted __proto__ entries as partial decoding", async () => {
|
||||
describe("binary Interface Builder dictionary keys", () => {
|
||||
it("does not report decoded __proto__ entries as omitted", async () => {
|
||||
const placeholder = "proto_key";
|
||||
const encoded = Buffer.from(
|
||||
buildBinary({
|
||||
@@ -1336,13 +1336,13 @@ describe("binary Interface Builder archive omissions", () => {
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "archive_decode",
|
||||
status: "partial",
|
||||
reason: "dictionary_entries_omitted",
|
||||
status: "complete",
|
||||
reason: null,
|
||||
}),
|
||||
);
|
||||
expect(result.limitations).toContain(
|
||||
"Contents/Resources/Prototype.nib: Dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(
|
||||
result.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1505,7 +1505,7 @@ describe("bounded Interface Builder archive decoding", () => {
|
||||
expect(result.graph.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("marks archive decoding partial when __proto__ entries are omitted", async () => {
|
||||
it("does not mark XML archive decoding partial for __proto__ entries", async () => {
|
||||
const root = await createTestTempDirectory("rea-ib-test-");
|
||||
const bundle = join(root, "Example.app");
|
||||
const resources = join(bundle, "Contents", "Resources");
|
||||
@@ -1523,14 +1523,13 @@ describe("bounded Interface Builder archive decoding", () => {
|
||||
expect(result.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "archive_decode",
|
||||
status: "partial",
|
||||
reason: "dictionary_entries_omitted",
|
||||
status: "complete",
|
||||
reason: null,
|
||||
}),
|
||||
);
|
||||
expect(result.graph.truncated).toBe(true);
|
||||
expect(result.limitations).toContain(
|
||||
"Contents/Resources/Prototype.nib: 1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(
|
||||
result.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("decodes an archive with a date year beyond four digits", async () => {
|
||||
|
||||
@@ -20,8 +20,8 @@ it.each([129, 3000])(
|
||||
parameters: { path: "Deep.plist" },
|
||||
platform: "linux",
|
||||
}).catch((cause: unknown) => cause);
|
||||
// Recursive decoding exhausts the call stack before the projection limit
|
||||
// can report it, so the limit diagnostic must come from the preflight.
|
||||
// The format owner must reject source depth before recursive decoding
|
||||
// exhausts the call stack.
|
||||
expect(rejected).toMatchObject({
|
||||
reason: "limit",
|
||||
message: `Cannot decode selected Foundation keyed archive: ${plistNestingLimitMessage()}`,
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
} from "../../../../src/native/CommandRunner.js";
|
||||
import { err, ok } from "../../../../src/domain/result.js";
|
||||
import { parseLipoOutput } from "../../../../src/native/parsers/lipo.js";
|
||||
import { inspectPlistSchema } from "../../../../src/domain/native/nativeInspection.js";
|
||||
import {
|
||||
inspectPlistSchema,
|
||||
inspectSignatureSchema,
|
||||
} from "../../../../src/domain/native/nativeInspection.js";
|
||||
import { parseCodeSignature } from "../../../../src/native/parsers/codesign.js";
|
||||
import { prepareProcessOwnershipInspection } from "../../../../src/process/ProcessOwnershipObservation.js";
|
||||
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
|
||||
@@ -1504,7 +1507,7 @@ class PrototypeEntitlementsRunner extends FixtureRunner {
|
||||
}
|
||||
|
||||
describe("native signature entitlements", () => {
|
||||
it("reports entitlement entries keyed __proto__ that the result omits", async () => {
|
||||
it("retains own entitlement dictionary keys through signature Evidence", async () => {
|
||||
directory = await createTestTempDirectory("rea-signature-entitlements-");
|
||||
const executable = join(directory, "fixture");
|
||||
await writeFile(executable, "fixture");
|
||||
@@ -1518,10 +1521,17 @@ describe("native signature entitlements", () => {
|
||||
|
||||
if (!signature.ok) throw signature.error;
|
||||
expect(signature.value.result).toMatchObject({
|
||||
entitlements: { "com.apple.security.app-sandbox": true },
|
||||
entitlements: {
|
||||
"com.apple.security.app-sandbox": true,
|
||||
["__proto__"]: { "com.apple.security.get-task-allow": true },
|
||||
},
|
||||
});
|
||||
expect(signature.value.limitations).toContain(
|
||||
"Entitlements: 1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
|
||||
const observed = inspectSignatureSchema.parse(signature.value.result);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(observed.entitlements, "__proto__"),
|
||||
).toBe(true);
|
||||
expect(signature.value.limitations).not.toContainEqual(
|
||||
expect.stringContaining("omitted"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+22
-11
@@ -15,16 +15,16 @@ export const keyedArchivePrototypeKeyFixture = async () => {
|
||||
<key>$archiver</key><string>NSKeyedArchiver</string>
|
||||
<key>$version</key><integer>100000</integer>
|
||||
<key>$objects</key><array><string>$null</string><dict>
|
||||
<key >__proto__</key ><string>omitted outer value</string>
|
||||
<key >__proto__</key ><string>recorded outer value</string>
|
||||
<key>kept</key><string>a b…c
d</string>
|
||||
<key>__rea_prototype_key__</key><string>ordinary key</string>
|
||||
<key>nested</key><dict>
|
||||
<key><![CDATA[__proto__]]><!-- trailing comment --></key>
|
||||
<string>omitted nested value</string>
|
||||
<string>recorded nested value</string>
|
||||
<key>value</key><integer>7</integer>
|
||||
</dict>
|
||||
</dict></array>
|
||||
<key>$top</key><dict><key>root</key><dict>
|
||||
<key>$top</key><dict><key>__proto__</key><dict><key>CF$UID</key><integer>1</integer></dict><key>root</key><dict>
|
||||
<key>CF$UID</key><integer>1</integer>
|
||||
</dict></dict>
|
||||
</dict></plist>`);
|
||||
@@ -32,27 +32,38 @@ export const keyedArchivePrototypeKeyFixture = async () => {
|
||||
return { path, digest: createHash("sha256").update(bytes).digest("hex") };
|
||||
};
|
||||
|
||||
/** Require original source identity, ordinary values and the exact omission count. */
|
||||
/** Require original source identity and every decoded dictionary member. */
|
||||
export const expectKeyedArchivePrototypeKeys = (
|
||||
value: unknown,
|
||||
digest: string,
|
||||
) => {
|
||||
const result = keyedArchiveResultSchema.parse(value);
|
||||
expect(result.archive_sha256).toBe(digest);
|
||||
expect(result.roots).toEqual({ root: { CF$UID: 1 } });
|
||||
expect(result.roots).toEqual({
|
||||
["__proto__"]: { CF$UID: 1 },
|
||||
root: { CF$UID: 1 },
|
||||
});
|
||||
expect(Object.hasOwn(result.roots, "__proto__")).toBe(true);
|
||||
expect(result.references).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: null,
|
||||
path: ["__proto__"],
|
||||
target: 1,
|
||||
status: "resolved",
|
||||
}),
|
||||
);
|
||||
expect(result.objects).toHaveLength(2);
|
||||
expect(result.objects[1]).toMatchObject({
|
||||
id: 1,
|
||||
kind: "dictionary",
|
||||
value: {
|
||||
["__proto__"]: "recorded outer value",
|
||||
kept: "a\rb\u0085c\u2028d",
|
||||
__rea_prototype_key__: "ordinary key",
|
||||
nested: { value: 7 },
|
||||
nested: { ["__proto__"]: "recorded nested value", value: 7 },
|
||||
},
|
||||
});
|
||||
expect(result.limitations).toContain(
|
||||
"2 dictionary entries keyed __proto__ were omitted because REA results cannot represent that key.",
|
||||
);
|
||||
expect(JSON.stringify(result)).not.toContain("omitted outer value");
|
||||
expect(JSON.stringify(result)).not.toContain("omitted nested value");
|
||||
expect(
|
||||
result.limitations.some((value) => value.includes("keyed __proto__")),
|
||||
).toBe(false);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user