diff --git a/docs/native-investigation.md b/docs/native-investigation.md index fbdbca2624..eb22fa8507 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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 diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts index 6f907730cb..3303b44478 100644 --- a/src/application/AppBundleExecutable.ts +++ b/src/application/AppBundleExecutable.ts @@ -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 => diff --git a/src/artifacts/apple/BinaryPlistMaterializer.test.ts b/src/artifacts/apple/BinaryPlistMaterializer.test.ts new file mode 100644 index 0000000000..07266303cd --- /dev/null +++ b/src/artifacts/apple/BinaryPlistMaterializer.test.ts @@ -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; + 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(); +}); diff --git a/src/artifacts/apple/BinaryPlistMaterializer.ts b/src/artifacts/apple/BinaryPlistMaterializer.ts new file mode 100644 index 0000000000..2d0086a315 --- /dev/null +++ b/src/artifacts/apple/BinaryPlistMaterializer.ts @@ -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; +}; diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 7456be7aae..ef2c593229 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -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(); const unexaminedSources = new Map(); const unresolvedConnections = new Map(); - const prototypeKeyOmissions = new Map(); const unknownDates = new Map(); const datePrecisionOverlaps = new Map(); const unknownScalars = new Map(); @@ -178,15 +175,7 @@ const decodeInterfaceBuilderBundle = async ( const isNibArchive = hasNibArchiveSignature(bytes); let nib: ReturnType | 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; incompleteHierarchies: ReadonlyMap; unresolvedConnections: ReadonlyMap; - prototypeKeyOmissions: ReadonlyMap; unknownDates: ReadonlyMap; datePrecisionOverlaps: ReadonlyMap; unknownScalars: ReadonlyMap; @@ -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, diff --git a/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts b/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts index 0e1a36cfaf..b32dee98fd 100644 --- a/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts +++ b/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts @@ -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(" 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); }); }); diff --git a/src/artifacts/apple/KeyedArchiveReader.test.ts b/src/artifacts/apple/KeyedArchiveReader.test.ts index 93bef7e930..eb571c7efa 100644 --- a/src/artifacts/apple/KeyedArchiveReader.test.ts +++ b/src/artifacts/apple/KeyedArchiveReader.test.ts @@ -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( - "$top", - "__proto__x$top", - ); + it("preserves an XML object dictionary keyed __proto__", () => { + const xml = build({ + ...archive, + $objects: ["$null", { prototypeKey: "x" }], + }).replace("prototypeKey", "__proto__"); 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); }); diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 72472fd465..5101c1f4ba 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -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)]), diff --git a/src/artifacts/apple/PlistScalars.test.ts b/src/artifacts/apple/PlistScalars.test.ts index 41d9a434da..afee17e250 100644 --- a/src/artifacts/apple/PlistScalars.test.ts +++ b/src/artifacts/apple/PlistScalars.test.ts @@ -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)); diff --git a/src/artifacts/apple/PlistScalars.ts b/src/artifacts/apple/PlistScalars.ts index 000090fbfc..075b14bbf5 100644 --- a/src/artifacts/apple/PlistScalars.ts +++ b/src/artifacts/apple/PlistScalars.ts @@ -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 { + 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, @@ -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 = diff --git a/src/artifacts/apple/PropertyListDecodeBudget.ts b/src/artifacts/apple/PropertyListDecodeBudget.ts index 7498a67d80..2ae8ce22a9 100644 --- a/src/artifacts/apple/PropertyListDecodeBudget.ts +++ b/src/artifacts/apple/PropertyListDecodeBudget.ts @@ -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(); + const sourceContainers = new Map(); const dictionaryKeys = new Map(); 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(); - if (entry.path !== undefined && type === 13) { - const lastKeyIndexes = new Map(); - 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, diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.test.ts b/src/domain/apple/interfaceBuilderKeyedArchive.test.ts index da14deb826..222c8a8c22 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.test.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.test.ts @@ -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 = {}, top: Record = { 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"], diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index 2d91c81487..12f5bdd6e1 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -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; @@ -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 = {}; - 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 => - recordSchema.safeParse(value).data ?? {}; + value !== null && + typeof value === "object" && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) + ? (value as Record) + : {}; /** First non-empty string among the candidates, otherwise null. */ export const firstString = (...values: unknown[]): string | null => { diff --git a/src/domain/apple/keyedArchive.test.ts b/src/domain/apple/keyedArchive.test.ts index 1717c86958..227909e688 100644 --- a/src/domain/apple/keyedArchive.test.ts +++ b/src/domain/apple/keyedArchive.test.ts @@ -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(() => diff --git a/src/domain/apple/keyedArchive.ts b/src/domain/apple/keyedArchive.ts index 97795c346b..1593d0f22f 100644 --- a/src/domain/apple/keyedArchive.ts +++ b/src/domain/apple/keyedArchive.ts @@ -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(), diff --git a/src/domain/native/nativeInvestigationGraph.test.ts b/src/domain/native/nativeInvestigationGraph.test.ts index b46734fb36..91dbf7c992 100644 --- a/src/domain/native/nativeInvestigationGraph.test.ts +++ b/src/domain/native/nativeInvestigationGraph.test.ts @@ -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); +}); diff --git a/src/domain/native/nativeInvestigationGraph.ts b/src/domain/native/nativeInvestigationGraph.ts index 5bea77081c..334875dcea 100644 --- a/src/domain/native/nativeInvestigationGraph.ts +++ b/src/domain/native/nativeInvestigationGraph.ts @@ -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), }); diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts index 8d5dab4978..9ab1381548 100644 --- a/src/domain/propertyListKeys.test.ts +++ b/src/domain/propertyListKeys.test.ts @@ -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([ "__proto__", "__proto__", "", -])("omits and counts a dictionary keyed %s", (key) => { +])("preserves an own dictionary member keyed %s", (key) => { const parsed = parseXmlPropertyList( plist( `CFBundleExecutableApp${key}pollutedlist${key}xkept1`, ), ); 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__ ": "__proto__", __rea_prototype_key__: "__proto__", }, - omittedPrototypeKeys: 0, }); }); @@ -59,8 +57,7 @@ it("keeps a segmented entity-encoded key that decodes to the placeholder", () => "__reakept__proto__x", ); 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", () => { "raw__proto__x", ); 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( "__prokept__proto__omitted", ); 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&\r\u0085\u2028]]>post": "a\rb" }, - omittedPrototypeKeys: 0, }); }); @@ -111,7 +95,7 @@ it("does not infer key identity from nested elements", () => { "prenestedkept__proto__nestedomitted", ), ), - ).toEqual({ value: { pre: "kept" }, omittedPrototypeKeys: 1 }); + ).toEqual({ value: { pre: "kept", ["__proto__"]: "omitted" } }); }); it.each([ @@ -127,7 +111,6 @@ it.each([ .join(""); expect(parseXmlPropertyList(plist(`${entries}`))).toEqual({ value: { ab: 3 }, - omittedPrototypeKeys: 0, }); }); @@ -138,7 +121,6 @@ it("keeps empty keys and alias-looking source keys distinct", () => { "ma42"; expect(parseXmlPropertyList(plist(`${entries}`))).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", () `; expect(parseXmlPropertyList(text)).toEqual({ value: { a: "", b: ["x"] }, - omittedPrototypeKeys: 0, }); }); @@ -315,7 +290,6 @@ it("ignores complete DOCTYPE internal subsets when checking value depth", () => ]>x`; expect(parseXmlPropertyList(text)).toEqual({ value: "x", - omittedPrototypeKeys: 0, }); }); @@ -337,24 +311,25 @@ it.each(["\n", "\r\n", "\r"])( `${newline}kept keya b…c
d${newline}__proto__omitted`, ); 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( `${lines}__proto__omittednested${lines}__proto__omittedkept`, ); 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", + }, }); }, ); diff --git a/src/domain/propertyListKeys.ts b/src/domain/propertyListKeys.ts index b690d083e7..e3d0ccd1fd 100644 --- a/src/domain/propertyListKeys.ts +++ b/src/domain/propertyListKeys.ts @@ -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); diff --git a/src/domain/propertyListOpenStep.test.ts b/src/domain/propertyListOpenStep.test.ts new file mode 100644 index 0000000000..398715211f --- /dev/null +++ b/src/domain/propertyListOpenStep.test.ts @@ -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 = (, <0>, , <>); }", + '/* 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; + 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, + ); + }, +); diff --git a/src/domain/propertyListOpenStep.ts b/src/domain/propertyListOpenStep.ts new file mode 100644 index 0000000000..2aa13afe97 --- /dev/null +++ b/src/domain/propertyListOpenStep.ts @@ -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 = {}; + 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); +}; diff --git a/src/domain/propertyListOpenStepComments.test.ts b/src/domain/propertyListOpenStepComments.test.ts index 13f77cdbbc..851e66d45f 100644 --- a/src/domain/propertyListOpenStepComments.test.ts +++ b/src/domain/propertyListOpenStepComments.test.ts @@ -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()); diff --git a/src/domain/propertyListXml.test.ts b/src/domain/propertyListXml.test.ts index 47efef1c23..12318e92c1 100644 --- a/src/domain/propertyListXml.test.ts +++ b/src/domain/propertyListXml.test.ts @@ -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( "__proto__", @@ -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(`${value}`).value, + ).toBe(value); + expect( + parseXmlPropertyList(`${value}`) + .value, + ).toEqual([value]); + }, +); diff --git a/src/domain/propertyListXml.ts b/src/domain/propertyListXml.ts index 31ed74997f..5efb27738b 100644 --- a/src/domain/propertyListXml.ts +++ b/src/domain/propertyListXml.ts @@ -92,7 +92,7 @@ export const decodeXmlPropertyList = ( } if (key !== undefined) throw new TypeError("Missing value for key while parsing "); - // 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": diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 1000cb97ba..c19d728e1f 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -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(""); - 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 + "".length), ); @@ -1506,7 +1496,6 @@ const parseEntitlements = ( const value = numbers.project(projected.value); return { value, - omittedPrototypeKeys: decoded.omittedPrototypeKeys, limitations: [ ...numbers.limitations(), ...(projected.nonFiniteRealCount === 0 diff --git a/src/native/parsers/plist.test.ts b/src/native/parsers/plist.test.ts index 69f24d1d7d..2837a0a9e5 100644 --- a/src/native/parsers/plist.test.ts +++ b/src/native/parsers/plist.test.ts @@ -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( - "user42__proomittedBlobAA==", + "user42__proretainedBlobAA==", ); 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 = + "__proto____proto__nestedconstructorauthoredCFBundleExecutableSelected"; + 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"), + ); + } +}); diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index 9f7f840517..0fd1b9c132 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -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; + 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 => { 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) + : 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 { diff --git a/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts b/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts index 0b137347b8..faf5226c3f 100644 --- a/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts +++ b/tests/acceptance/applications/interfaceBuilderUIKitKeyedArchive.test.ts @@ -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", ); diff --git a/tests/acceptance/applications/nativePlistBinaryScalars.test.ts b/tests/acceptance/applications/nativePlistBinaryScalars.test.ts index c14bea7051..d659bb1110 100644 --- a/tests/acceptance/applications/nativePlistBinaryScalars.test.ts +++ b/tests/acceptance/applications/nativePlistBinaryScalars.test.ts @@ -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); diff --git a/tests/boundary/cli/nativePlistPrototypeKeyCli.test.ts b/tests/boundary/cli/nativePlistPrototypeKeyCli.test.ts index 0a75f0e386..c28699396a 100644 --- a/tests/boundary/cli/nativePlistPrototypeKeyCli.test.ts +++ b/tests/boundary/cli/nativePlistPrototypeKeyCli.test.ts @@ -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", "BlobAAEC"], ] 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", + ); }, ); }, diff --git a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts index 5caa814b8e..14317c3ec1 100644 --- a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts +++ b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts @@ -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 = "__proto__42keptvalue"; @@ -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); }, ); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 76e25fb894..e67b515fe6 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -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 () => { diff --git a/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts b/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts index eb020e9f40..bfd729ee76 100644 --- a/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts +++ b/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts @@ -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()}`, diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index 40d727bf7b..a058b50b7f 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -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"), ); }); }); diff --git a/tests/fixtures/keyedArchivePrototypeKeys.ts b/tests/fixtures/keyedArchivePrototypeKeys.ts index 421935d4d0..c738d78678 100644 --- a/tests/fixtures/keyedArchivePrototypeKeys.ts +++ b/tests/fixtures/keyedArchivePrototypeKeys.ts @@ -15,16 +15,16 @@ export const keyedArchivePrototypeKeyFixture = async () => { $archiverNSKeyedArchiver $version100000 $objects$null - __proto__omitted outer value + __proto__recorded outer value kepta b…c
d __rea_prototype_key__ordinary key nested - omitted nested value + recorded nested value value7 - $toproot + $top__proto__CF$UID1root CF$UID1 `); @@ -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); };