From d672cc23998b6cbb21c485f118595ebfb82fed33 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:09:29 +0800 Subject: [PATCH] fix(apple): preserve binary plist scalar source facts Decode native binary plists from bounded owned source bytes before conversion can erase scalar values. Share scalar preflight and isolate overlapping decoder records while retaining exact integers, date uncertainty, and original bytes. Refuse numeric payloads that read structural table bytes, and avoid retaining projection metadata when callers only need structural validation. Closes #5701 Closes #5703 Closes #5705 Closes #5782 Closes #5783 Closes #5792 --- docs/native-investigation.md | 14 +- src/application/AppBundleExecutable.ts | 2 +- src/artifacts/apple/BinaryPlistDate.test.ts | 20 - src/artifacts/apple/BinaryPlistDate.ts | 18 - .../apple/InterfaceBuilderAnalysis.ts | 61 +- .../InterfaceBuilderDecodeBudget.test.ts | 79 ++- .../apple/InterfaceBuilderDecodeBudget.ts | 354 +----------- .../apple/KeyedArchiveReader.test.ts | 149 ++++- src/artifacts/apple/KeyedArchiveReader.ts | 29 +- .../apple/PlistNumberLiterals.test.ts | 77 --- src/artifacts/apple/PlistNumberLiterals.ts | 177 ------ src/artifacts/apple/PlistScalars.test.ts | 386 +++++++++++++ src/artifacts/apple/PlistScalars.ts | 378 +++++++++++++ .../apple/PropertyListDecodeBudget.ts | 522 ++++++++++++++++++ src/domain/apple/plistValue.ts | 4 + src/native/NativeMacOSProvider.ts | 260 +++++---- src/native/NativePlistSource.ts | 77 ++- src/native/parsers/plist.test.ts | 428 ++++++++++++++ src/native/parsers/plist.ts | 117 +++- .../applications/binaryPlistDates.test.ts | 271 +++++++++ .../nativePlistBinaryScalars.test.ts | 187 +++++++ tests/boundary/cli/nativePlistCli.test.ts | 30 +- .../filesystem/appleXmlDeclarations.test.ts | 2 +- .../native/nativePlistBinarySource.test.ts | 232 ++++++++ .../native/plistSourceFormat.test.ts | 37 +- tests/fixtures/binaryPlistObjectTable.ts | 72 +++ tests/fixtures/binaryPlistScalarAlias.ts | 47 ++ 27 files changed, 3232 insertions(+), 798 deletions(-) delete mode 100644 src/artifacts/apple/BinaryPlistDate.test.ts delete mode 100644 src/artifacts/apple/BinaryPlistDate.ts delete mode 100644 src/artifacts/apple/PlistNumberLiterals.test.ts delete mode 100644 src/artifacts/apple/PlistNumberLiterals.ts create mode 100644 src/artifacts/apple/PlistScalars.test.ts create mode 100644 src/artifacts/apple/PlistScalars.ts create mode 100644 src/artifacts/apple/PropertyListDecodeBudget.ts create mode 100644 tests/acceptance/applications/binaryPlistDates.test.ts create mode 100644 tests/acceptance/applications/nativePlistBinaryScalars.test.ts create mode 100644 tests/boundary/providers/native/nativePlistBinarySource.test.ts create mode 100644 tests/fixtures/binaryPlistObjectTable.ts create mode 100644 tests/fixtures/binaryPlistScalarAlias.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 6d6fd4d323..75eb2eb295 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -298,13 +298,20 @@ markup, including UTF-8 and BOM-marked UTF-16 sources. Unidentified text and OpenStep plists, including a data-only value such as ``, report `unknown` even when plutil decodes their values through an XML conversion. +Binary plist inspection reads the original bytes through an owned file descriptor, +so conversion cannot erase control characters, fractional dates, negative zero, +or the high word of a 128-bit integer. It accepts at most 64 MiB of input and +reserves an estimated 64 MiB decode budget before parsing; this estimate is not +a hard process-memory bound. Binary-source provenance records the `file` +classification without claiming a `plutil` conversion. + A plist explicitly selected through the path input or as the active plist target reports an unreadable or malformed selection as `invalid_request`. A missing or unreadable default plist of another target reports `capability_unavailable`. Permission failures remain `access_denied`; output and cleanup failures retain their own error categories. -A plist decode refusal includes plutil's diagnostic from either output stream. +A conversion refusal includes plutil's diagnostic from either output stream. `details.captured_output` retains bounded stdout/stderr excerpts, observed byte totals and exit status. `truncated: true` means those excerpts are incomplete. @@ -714,8 +721,9 @@ leap seconds) and out-of-range dates remain explicit unknowns. REA does not emulate CoreFoundation's normalization of overflowing calendar fields. Invalid dates returned by other decoders retain `{ "$plist_type": "date", "iso8601": null }` with a counted limitation. `inspect_plist` and Interface Builder decoding use -the same projection. Binary keyed-archive and Interface Builder plist decoding -refuse a reachable date whose marker is not `0x33`. A valid `0x33` date is +the same projection. Binary plist decoding refuses a reachable date whose marker +is not `0x33`; date and numeric payloads must end before the offset table. +Valid overlapping object records retain their independent source values. A valid `0x33` date is reported with an ISO-8601 timestamp only when its float64 seconds are exactly the canonical encoding of that millisecond instant. Any other finite binary date uses `{ "$plist_type": "date", "iso8601": null }` and the counted diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts index eb160ead28..ca7dfba21e 100644 --- a/src/application/AppBundleExecutable.ts +++ b/src/application/AppBundleExecutable.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { parseBinary } from "plist"; import { z } from "zod"; -import { preflightPropertyListDecode } from "../artifacts/apple/InterfaceBuilderDecodeBudget.js"; +import { preflightPropertyListDecode } from "../artifacts/apple/PropertyListDecodeBudget.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import { isPathWithinRoot } from "../domain/localPath.js"; import { diff --git a/src/artifacts/apple/BinaryPlistDate.test.ts b/src/artifacts/apple/BinaryPlistDate.test.ts deleted file mode 100644 index c8cd376d14..0000000000 --- a/src/artifacts/apple/BinaryPlistDate.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { binaryPlistDateRepresentable } from "./BinaryPlistDate.js"; - -describe("binary plist date instants", () => { - it("keeps canonical millisecond encodings and rejects nearby floats", () => { - expect(binaryPlistDateRepresentable(0.1)).toBe(true); - expect(binaryPlistDateRepresentable(0)).toBe(true); - expect(binaryPlistDateRepresentable(0.001)).toBe(true); - expect(binaryPlistDateRepresentable(-0)).toBe(true); - expect(binaryPlistDateRepresentable(0.1005)).toBe(false); - expect(binaryPlistDateRepresentable(0.1004)).toBe(false); - const next = Buffer.alloc(8); - next.writeDoubleBE(0.1); - next.writeBigUInt64BE(next.readBigUInt64BE(0) + 1n); - expect(binaryPlistDateRepresentable(next.readDoubleBE(0))).toBe(false); - expect(binaryPlistDateRepresentable(Number.NaN)).toBe(false); - expect(binaryPlistDateRepresentable(Number.POSITIVE_INFINITY)).toBe(false); - }); -}); diff --git a/src/artifacts/apple/BinaryPlistDate.ts b/src/artifacts/apple/BinaryPlistDate.ts deleted file mode 100644 index cfa5128850..0000000000 --- a/src/artifacts/apple/BinaryPlistDate.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Milliseconds between 1970-01-01 and 2001-01-01. - * Matches plist@5 `parse-binary.js` `EPOCH_2001`. - */ -const APPLE_REFERENCE_EPOCH_MILLISECONDS = 978_307_200_000; - -/** - * Whether plist@5's `new Date(seconds * 1000 + epoch)` denotes the same - * float64 seconds. Each source payload is judged on its own; distinct - * instants can share one rounded millisecond. - */ -export const binaryPlistDateRepresentable = (seconds: number): boolean => { - if (!Number.isFinite(seconds)) return false; - const decoded = new Date(seconds * 1000 + APPLE_REFERENCE_EPOCH_MILLISECONDS); - const time = Date.prototype.getTime.call(decoded); - if (!Number.isFinite(time)) return false; - return (time - APPLE_REFERENCE_EPOCH_MILLISECONDS) / 1000 === seconds; -}; diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 1b04451cd5..bcf6820146 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -10,6 +10,7 @@ import { import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { projectPlistValue, + binaryDatePrecisionOverlapLimitation, unknownDateLimitation, unknownScalarLimitation, } from "../../domain/apple/plistValue.js"; @@ -18,8 +19,9 @@ import { parseXmlPropertyList, } from "../../domain/propertyListKeys.js"; import { decodeNibArchive, type NibArchiveDocument } from "./NibArchive.js"; +import { preflightPropertyListDecode } from "./PropertyListDecodeBudget.js"; import { - preflightPropertyListDecode, + interfaceBuilderDecodeBudgetFailure, InterfaceBuilderDecodeBudget, InterfaceBuilderDecodeBudgetExceeded, } from "./InterfaceBuilderDecodeBudget.js"; @@ -39,9 +41,10 @@ import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js" import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js"; import { - decodeBinaryPlistNumbers, - scanBinaryPlistNumberLiterals, -} from "./PlistNumberLiterals.js"; + decodeBinaryPlistScalars, + scanBinaryPlistScalars, + type BinaryPlistScalarDecodePlan, +} from "./PlistScalars.js"; import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js"; import { hasBinaryPlistSignature, @@ -70,6 +73,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { const unresolvedConnections = new Map(); const prototypeKeyOmissions = new Map(); const unknownDates = new Map(); + const datePrecisionOverlaps = new Map(); const unknownScalars = new Map(); let omitted = 0; let attempted = 0; @@ -118,7 +122,14 @@ export const analyzeInterfaceBuilderBundle = async (input: { let nib: ReturnType | null = null; let xmlText: string | undefined; let binaryPrototypeKeyObserved = false; - let binaryDatePayloadOffsets: readonly number[] = []; + let scalarPlan: BinaryPlistScalarDecodePlan = { + reachableDateOffsets: [], + overlappingDateOffsets: [], + reachableWideIntegerOffsets: [], + overlappingWideIntegerOffsets: [], + reachableNumericOffsets: [], + }; + let datePrecisionOverlapCount = 0; if (isNibArchive) { const documentBudget = new InterfaceBuilderDecodeBudget( decodeBudget.remainingBytes, @@ -139,7 +150,11 @@ export const analyzeInterfaceBuilderBundle = async (input: { bytes, decodeBudget.remainingBytes, xmlText, - { numberProjection: true }, + { + numberProjection: true, + budget: "the aggregate Interface Builder decode budget", + fail: interfaceBuilderDecodeBudgetFailure, + }, ); decodeBudget.reserve( decodedReservation.estimatedBytes, @@ -148,8 +163,9 @@ export const analyzeInterfaceBuilderBundle = async (input: { plistReservation = decodedReservation.estimatedBytes; binaryPrototypeKeyObserved = decodedReservation.binaryPrototypeKeyObserved; - binaryDatePayloadOffsets = - decodedReservation.binaryDatePayloadOffsets; + scalarPlan = decodedReservation; + datePrecisionOverlapCount = + decodedReservation.datePrecisionOverlapCount; } const { value: raw, @@ -158,12 +174,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { unknownScalarCount, numberLiterals, } = nib === null - ? decodePlist( - bytes, - xmlText, - binaryPrototypeKeyObserved, - binaryDatePayloadOffsets, - ) + ? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved, scalarPlan) : { value: nib.raw, omittedPrototypeKeys: 0, @@ -179,6 +190,8 @@ export const analyzeInterfaceBuilderBundle = async (input: { prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys); if (unknownDateCount !== 0) unknownDates.set(entry.path, unknownDateCount); + if (datePrecisionOverlapCount > 0) + datePrecisionOverlaps.set(entry.path, datePrecisionOverlapCount); if (unknownScalarCount !== 0) unknownScalars.set(entry.path, unknownScalarCount); const documentHash = createHash("sha256").update(bytes).digest("hex"); @@ -226,6 +239,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { unresolvedConnections, prototypeKeyOmissions, unknownDates, + datePrecisionOverlaps, unknownScalars, invalid, omittedArchives, @@ -296,6 +310,7 @@ const finalizeHierarchyCoverage = ( unresolvedConnections: ReadonlyMap; prototypeKeyOmissions: ReadonlyMap; unknownDates: ReadonlyMap; + datePrecisionOverlaps: ReadonlyMap; unknownScalars: ReadonlyMap; invalid: readonly string[]; omittedArchives: readonly { path: string; reason: string }[]; @@ -309,6 +324,7 @@ const finalizeHierarchyCoverage = ( unresolvedConnections, prototypeKeyOmissions, unknownDates, + datePrecisionOverlaps, unknownScalars, invalid, omittedArchives, @@ -398,6 +414,9 @@ const finalizeHierarchyCoverage = ( ...[...unknownDates].map( ([path, count]) => `${path}: ${unknownDateLimitation(count)}`, ), + ...[...datePrecisionOverlaps].map(([path, count]) => { + return `${path}: ${binaryDatePrecisionOverlapLimitation(count)}`; + }), ...[...unknownScalars].map( ([path, count]) => `${path}: ${unknownScalarLimitation(count)}`, ), @@ -767,7 +786,7 @@ const decodePlist = ( bytes: Buffer, xmlText: string | undefined, binaryPrototypeKeyObserved: boolean, - binaryDatePayloadOffsets: readonly number[], + scalarPlan: BinaryPlistScalarDecodePlan, ): { readonly value: JsonValue; readonly omittedPrototypeKeys: number | null; @@ -776,20 +795,16 @@ const decodePlist = ( readonly numberLiterals: PlistNumberLiterals; } => { const binary = hasBinaryPlistSignature(bytes); - const binaryNumberMetadata = binary - ? scanBinaryPlistNumberLiterals(bytes) + const binaryScalarMetadata = binary + ? scanBinaryPlistScalars(bytes, scalarPlan) : undefined; const { value, omittedPrototypeKeys, numberLiterals } = binary ? { value: parseBinary( - decodeBinaryPlistNumbers( - bytes, - binaryNumberMetadata!, - binaryDatePayloadOffsets, - ), + decodeBinaryPlistScalars(bytes, binaryScalarMetadata!, scalarPlan), ), omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0, - numberLiterals: binaryNumberMetadata!.literals, + numberLiterals: binaryScalarMetadata!.literals, } : parseXmlWithinDecodeBudget(xmlText ?? decodeXmlPlistText(bytes)); const projected = projectPlistValue(value); diff --git a/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts b/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts index 4354db08d8..dd87e23353 100644 --- a/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts +++ b/src/artifacts/apple/InterfaceBuilderDecodeBudget.test.ts @@ -6,10 +6,14 @@ import { buildBinary } from "plist"; import { describe, expect, it } from "vitest"; import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js"; -import { preflightPropertyListDecode } from "./InterfaceBuilderDecodeBudget.js"; +import { interfaceBuilderDecodeBudgetFailure } from "./InterfaceBuilderDecodeBudget.js"; +import { preflightPropertyListDecode } from "./PropertyListDecodeBudget.js"; import { unknownDateLimitation } from "../../domain/apple/plistValue.js"; import { parseXmlPropertyList } from "../../domain/propertyListKeys.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { scanBinaryPlistScalars } from "./PlistScalars.js"; +import { binaryPlistWithOverlappingDateAndData } from "../../../tests/fixtures/binaryPlistObjectTable.js"; +import { embedSubmillisecondDateInWideIntegerPayload } from "../../../tests/fixtures/binaryPlistScalarAlias.js"; const example = ""; @@ -34,8 +38,12 @@ it.each([ undefined, { numberProjection: true }, ).estimatedBytes; - if (_format === "binary") - expect(projected).toBeGreaterThanOrEqual(ordinary + bytes.length); + if (_format === "binary") { + const objectCount = Number(bytes.readBigUInt64BE(bytes.length - 24)); + expect(projected).toBeGreaterThanOrEqual( + ordinary + bytes.length + objectCount * 96, + ); + } expect(() => preflightPropertyListDecode(bytes, ordinary, undefined, { numberProjection: true, @@ -113,7 +121,15 @@ describe("inert XML declaration examples in Interface Builder archives", () => { ']> - preflightPropertyListDecode(Buffer.from(xml), 16 * 1024 * 1024), + preflightPropertyListDecode( + Buffer.from(xml), + 16 * 1024 * 1024, + undefined, + { + budget: "the aggregate Interface Builder decode budget", + fail: interfaceBuilderDecodeBudgetFailure, + }, + ), ).toThrow( "XML plist internal entity expansion exceeds the aggregate Interface Builder decode budget", ); @@ -143,6 +159,61 @@ describe("inert XML declaration examples in Interface Builder archives", () => { }); }); +it("retains explicit date uncertainty for an overlapping binary plist object", async () => { + const root = await createTestTempDirectory("rea-ib-binary-date-overlap-"); + const bundle = join(root, "Example.app"); + await mkdir(bundle); + const bytes = binaryPlistWithOverlappingDateAndData(); + await writeFile(join(bundle, "Panel.nib"), bytes); + + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + }); + + expect(result.limitations).toContainEqual( + expect.stringContaining( + "Panel.nib: 1 date value(s) are reported with unknown timestamps because their sub-millisecond source records overlap", + ), + ); +}); + +it("preserves aliased wide-integer precision and marks the date unknown in bundle output", async () => { + const root = await createTestTempDirectory("rea-ib-binary-scalar-alias-"); + const bundle = join(root, "Example.app"); + await mkdir(bundle); + const source = Buffer.from( + buildBinary({ + "com.apple.ibtool.document.objects": { + button: { + customClass: "UIButton", + wide: Buffer.alloc(16), + date: new Date(0), + }, + }, + "com.apple.ibtool.document.connections": {}, + "com.apple.ibtool.document.hierarchy": [], + "com.apple.ibtool.document.classes": {}, + }), + ); + const { bytes, integerDecimal } = + embedSubmillisecondDateInWideIntegerPayload(source); + await writeFile(join(bundle, "Panel.nib"), bytes); + + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "a".repeat(64), + }); + + const button = result.graph.nodes.find( + ({ attributes }) => attributes.interface_builder_object_id === "button", + ); + expect(button?.attributes).toMatchObject({ + date: { $plist_type: "date", iso8601: null }, + wide: { $plist_type: "integer", decimal: integerDecimal }, + }); +}); + describe("binary Interface Builder plist dates", () => { const control = Buffer.from( "62706c6973743030d4010203040506090c5924617263686976657258246f626a656374735424746f70582476657273696f6e5f100f4e534b657965644172636869766572a2070855246e756c6c333fb999999999999ad10a0b54726f6f74800112000186a008111b24293244474d56595e600000000000000101000000000000000d00000000000000000000000000000065", diff --git a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts index 4d47b5fdd2..11aad7169c 100644 --- a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts +++ b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts @@ -1,6 +1,3 @@ -import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js"; -import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js"; - /** Reason reported when an archive would exceed the shared decoding budget. */ export type InterfaceBuilderBudgetReason = "aggregate_decode_budget_exhausted"; @@ -15,6 +12,16 @@ export class InterfaceBuilderDecodeBudgetExceeded extends Error { } } +/** Map shared plist preflight limits to Interface Builder's public reason. */ +export const interfaceBuilderDecodeBudgetFailure = ( + _kind: "cycle" | "depth" | "expansion", + message: string, +): Error => + new InterfaceBuilderDecodeBudgetExceeded( + "aggregate_decode_budget_exhausted", + message, + ); + /** Shared byte budget for decoder records and projected archive data. */ export class InterfaceBuilderDecodeBudget { readonly #initialBytes: number; @@ -96,344 +103,3 @@ export class InterfaceBuilderDecodeBudget { return true; } } - -/** Preflight the representation created by the pinned plist decoder. */ -export const preflightPropertyListDecode = ( - bytes: Buffer, - maxBytes: number, - xmlText?: string, - options: Pick = {}, -): PlistDecodePreflight => { - const { numberProjection = false } = options; - const expansion = hasBinaryPlistSignature(bytes) - ? estimateBinaryPlistExpansion(bytes, maxBytes, { numberProjection }) - : { - estimatedBytes: estimateXmlPlistExpansion( - bytes, - maxBytes, - xmlText, - numberProjection, - ), - binaryPrototypeKeyObserved: false, - binaryDatePayloadOffsets: [], - }; - if (expansion.estimatedBytes > maxBytes) - throw new InterfaceBuilderDecodeBudgetExceeded( - "aggregate_decode_budget_exhausted", - "plist representation exceeds the aggregate Interface Builder decode budget", - ); - return expansion; -}; - -const estimateXmlPlistExpansion = ( - bytes: Buffer, - maxBytes: number, - xmlText?: string, - numberProjection = false, -): number => { - const xml = xmlText ?? decodeXmlPlistText(bytes); - const decodedSourceBytes = xml.length * 2; - let elementCount = 0; - // Reserve room for numeric source metadata and the projected attribute copy. - const elementBytes = numberProjection ? 256 : 128; - // Value nesting is bounded by the shared plist parser, which counts plist - // value depth; raw element depth would also count the envelope and keys. - for (const token of xmlPreflightTokens(xml)) { - if (token.doctype) { - // Only a declaration outside opaque text can introduce an internal - // subset. Brackets in quoted external identifiers are literal text. - if (token.internalSubset) - throw new InterfaceBuilderDecodeBudgetExceeded( - "aggregate_decode_budget_exhausted", - "XML plist internal entity expansion exceeds the aggregate Interface Builder decode budget", - ); - continue; - } - elementCount += 1; - if (decodedSourceBytes * 2 + elementCount * elementBytes > maxBytes) - return maxBytes + 1; - } - return decodedSourceBytes * 2 + elementCount * elementBytes; -}; - -/** Consume each XML span once; malformed tokens cannot restart suffix scans. */ -function* xmlPreflightTokens(xml: string) { - let cursor = 0; - while (cursor < xml.length) { - const start = xml.indexOf("<", cursor); - if (start < 0) return; - const opaqueEnd = xml.startsWith("" - : xml.startsWith("" - : xml.startsWith("" - : undefined; - if (opaqueEnd !== undefined) { - const end = xml.indexOf(opaqueEnd, start + 2); - if (end < 0) return; - cursor = end + opaqueEnd.length; - continue; - } - const doctype = /^") break; - else if (doctype && character === "[") internalSubset = true; - } - if (end === xml.length) return; - cursor = end + 1; - if (doctype || element) yield { doctype, internalSubset }; - } -} - -/** Resource a binary plist preflight found over its budget, or a malformed reference cycle. */ -export type BinaryPlistExpansionFailure = "cycle" | "depth" | "expansion"; - -const interfaceBuilderBudgetFailure = ( - _kind: BinaryPlistExpansionFailure, - message: string, -): Error => - new InterfaceBuilderDecodeBudgetExceeded( - "aggregate_decode_budget_exhausted", - message, - ); - -/** How a caller names and reports binary plist expansion failures. */ -export interface BinaryPlistExpansionOptions { - /** Include original number metadata and exact-integer attribute projection. */ - readonly numberProjection?: boolean; - /** Budget named in failure messages. */ - readonly budget?: string; - /** Error for budget, depth, and reference-cycle failures. */ - readonly fail?: (kind: BinaryPlistExpansionFailure, message: string) => Error; - /** - * Structural bounds: "strict" keeps objects and the offset table before the - * trailer; "decoder" follows the decoder's object-table boundary policy - * while still rejecting structures whose coercion would lose plist facts. - */ - readonly bounds?: "strict" | "decoder"; -} - -/** Preflight size and binary-key observations; XML keys are inspected by decode. */ -export interface PlistDecodePreflight { - readonly estimatedBytes: number; - readonly binaryPrototypeKeyObserved: boolean; - /** Offsets of reachable `0x33` date payloads. Empty for XML. */ - readonly binaryDatePayloadOffsets: readonly number[]; -} - -const PROTOTYPE_KEY = "__proto__"; - -/** - * Count every expanded binary-plist reference before calling plist.parseBinary, - * which recurses on each reference and copies shared containers. Malformed - * structure, including a reachable date marker other than `0x33`, throws - * TypeError; budget, depth, and cycle failures use `fail`. - */ -export const estimateBinaryPlistExpansion = ( - bytes: Buffer, - maxBytes: number, - { - budget = "the aggregate Interface Builder decode budget", - fail = interfaceBuilderBudgetFailure, - bounds = "strict", - numberProjection = false, - }: BinaryPlistExpansionOptions = {}, -): PlistDecodePreflight => { - if (bytes.length < 40) - throw new TypeError("binary plist trailer is truncated"); - const trailer = bytes.length - 32; - const offsetSize = bytes[trailer + 6] ?? 0; - const referenceSize = bytes[trailer + 7] ?? 0; - if ( - ![1, 2, 4, 8].includes(offsetSize) || - ![1, 2, 4, 8].includes(referenceSize) - ) - throw new TypeError("binary plist trailer integer sizes are invalid"); - const readInteger = (offset: number, size: number): number => { - if (offset < 0 || size < 1 || offset + size > bytes.length) return -1; - let value = 0; - for (let index = 0; index < size; index += 1) - value = value * 256 + (bytes[offset + index] ?? 0); - return Number.isSafeInteger(value) ? value : -1; - }; - const objectCount = readInteger(trailer + 8, 8); - const topObject = readInteger(trailer + 16, 8); - const offsetTable = readInteger(trailer + 24, 8); - const tableEnd = bounds === "strict" ? trailer : bytes.length; - const objectsEnd = bounds === "strict" ? offsetTable : bytes.length; - if ( - objectCount < 1 || - topObject < 0 || - topObject >= objectCount || - offsetTable < 8 || - offsetTable + objectCount * offsetSize > tableEnd - ) - throw new TypeError("binary plist trailer or object table is invalid"); - // Metadata covers the full object table; projected values may repeat through references. - const objectBytes = numberProjection ? 144 : 16; - const expandedNodeBytes = numberProjection ? 224 : 96; - // Number projection may require one same-sized parser-compatible input copy. - // That copy carries wide-integer rewrites and unrepresentable date payloads. - // Reserve for it before the caller allocates that copy. - let estimate = - bytes.length * (numberProjection ? 3 : 2) + objectCount * objectBytes; - let binaryPrototypeKeyObserved = false; - const datePayloadOffsets: number[] = []; - const recordedDateObjects = new Set(); - if (estimate > maxBytes) - throw fail("expansion", `binary plist object table exceeds ${budget}`); - - const active = new Set(); - const pending: Array<{ - object: number; - depth: number; - exit: boolean; - dictionaryKey: boolean; - }> = [{ object: topObject, depth: 0, exit: false, dictionaryKey: false }]; - let pendingObjectCount = 1; - while (pending.length > 0) { - const entry = pending.pop(); - if (entry === undefined) break; - if (entry.exit) { - active.delete(entry.object); - continue; - } - pendingObjectCount -= 1; - if (entry.depth > 128) - throw fail("depth", `binary plist nesting exceeds ${budget}`); - if (active.has(entry.object)) - throw fail("cycle", `binary plist reference cycle exceeds ${budget}`); - estimate += expandedNodeBytes; - if (estimate > maxBytes) - throw fail( - "expansion", - `binary plist reference expansion exceeds ${budget}`, - ); - const offset = readInteger( - offsetTable + entry.object * offsetSize, - offsetSize, - ); - if (offset < 8 || offset >= objectsEnd) - throw new TypeError("binary plist object offset is invalid"); - const marker = bytes[offset] ?? 0; - const type = marker >> 4; - if (entry.dictionaryKey && type !== 5 && type !== 6) - throw new TypeError("binary plist dictionary key is not a string"); - let size = marker & 0x0f; - let cursor = offset + 1; - if (size === 0x0f && type !== 0 && type !== 8) { - const extMarker = bytes[cursor] ?? 0; - if (extMarker >> 4 !== 1 || (extMarker & 0x0f) > 3) - throw new TypeError("binary plist extended object size is invalid"); - cursor += 1; - const integerSize = 1 << (extMarker & 0x0f); - size = readInteger(cursor, integerSize); - cursor += integerSize; - if (size < 0 || cursor > objectsEnd) - throw new TypeError("binary plist extended object size is invalid"); - } - if (type === 4 || type === 5 || type === 6) { - 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( - "expansion", - `binary plist scalar expansion exceeds ${budget}`, - ); - continue; - } - if (type === 3) { - // Binary plist dates are only marker 0x33 plus an 8-byte float inside - // the object region. Decoder bounds let other objects use trailing - // bytes; a date there would be parsed as a timestamp. The payload - // offset is retained so the decoder can judge that source instant - // without matching rounded timestamps across objects. - if ( - offset >= offsetTable || - !Number.isSafeInteger(cursor + 8) || - cursor + 8 > offsetTable - ) - throw new TypeError("binary plist date object is truncated"); - if (marker !== 0x33) - throw new TypeError( - `binary plist date marker 0x${marker.toString(16)} is invalid`, - ); - if (!recordedDateObjects.has(entry.object)) { - recordedDateObjects.add(entry.object); - datePayloadOffsets.push(cursor); - } - continue; - } - if (type !== 10 && type !== 13) continue; - const childCount = type === 13 ? size * 2 : size; - const refsEnd = cursor + childCount * referenceSize; - if (!Number.isSafeInteger(refsEnd) || refsEnd > objectsEnd) - throw new TypeError("binary plist reference table is truncated"); - const availableNodes = Math.floor( - (maxBytes - estimate) / expandedNodeBytes, - ); - if (childCount > availableNodes - pendingObjectCount) - throw fail( - "expansion", - `binary plist reference expansion exceeds ${budget}`, - ); - active.add(entry.object); - pending.push({ - object: entry.object, - depth: entry.depth, - exit: true, - 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"); - pending.push({ - object: reference, - depth: entry.depth + 1, - exit: false, - dictionaryKey: type === 13 && index < size, - }); - } - pendingObjectCount += childCount; - } - return { - estimatedBytes: estimate, - binaryPrototypeKeyObserved, - binaryDatePayloadOffsets: datePayloadOffsets, - }; -}; diff --git a/src/artifacts/apple/KeyedArchiveReader.test.ts b/src/artifacts/apple/KeyedArchiveReader.test.ts index 783dd78673..c4a1325ad6 100644 --- a/src/artifacts/apple/KeyedArchiveReader.test.ts +++ b/src/artifacts/apple/KeyedArchiveReader.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { build, buildBinary, type PlistValue } from "plist"; -import { estimateBinaryPlistExpansion } from "./InterfaceBuilderDecodeBudget.js"; +import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js"; import { decodeKeyedArchiveBytes, inspectBundleKeyedArchive, @@ -12,6 +12,13 @@ import { import { unknownDateLimitation } from "../../domain/apple/plistValue.js"; import { deeplyNestedXmlPlist } from "../../../tests/fixtures/keyedArchiveXmlNesting.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { scanBinaryPlistScalars } from "./PlistScalars.js"; +import { embedSubmillisecondDateInWideIntegerPayload } from "../../../tests/fixtures/binaryPlistScalarAlias.js"; +import { + readBinaryPlistObjectOffset, + readBinaryPlistObjectTableLayout, + writeBinaryPlistObjectOffset, +} from "../../../tests/fixtures/binaryPlistObjectTable.js"; const archive = { $archiver: "NSKeyedArchiver", @@ -32,6 +39,146 @@ const archive = { ], $top: { root: { UID: 1 }, other: { UID: 2 } }, }; + +const binaryArchiveWithDate = (): Buffer => + Buffer.from( + buildBinary({ + ...archive, + $objects: ["$null", new Date("2001-01-01T00:00:00.100Z")], + $top: { root: { UID: 1 } }, + }), + ); + +const binaryArchiveWithOverlappingDate = (): Buffer => { + const bytes = Buffer.from( + buildBinary({ + ...archive, + $objects: [ + "$null", + new Date("2001-01-01T00:00:00.000Z"), + Buffer.alloc(0), + ], + $top: { root: { UID: 1 } }, + }), + ); + const date = scanBinaryPlistScalars(bytes).dates[0]; + if (date === undefined) + throw new Error("binary archive fixture lost its date"); + bytes.writeDoubleBE(0.0005, date.payloadOffset); + const objectTable = readBinaryPlistObjectTableLayout(bytes); + for (let objectId = 0; objectId < objectTable.objectCount; objectId += 1) { + const offset = readBinaryPlistObjectOffset(bytes, objectTable, objectId); + if (offset !== date.offset && bytes[offset] === 0x40) { + writeBinaryPlistObjectOffset( + bytes, + objectTable, + objectId, + date.payloadOffset + 1, + ); + return bytes; + } + } + throw new Error("binary archive fixture lost its empty data object"); +}; + +const binaryArchiveDateCrossingOffsetTable = (): Buffer => { + const bytes = Buffer.from(binaryArchiveWithDate()); + const date = scanBinaryPlistScalars(bytes).dates[0]; + if (date === undefined) + throw new Error("binary archive fixture lost its date"); + const objectTable = readBinaryPlistObjectTableLayout(bytes); + let dateObjectId = -1; + for (let objectId = 0; objectId < objectTable.objectCount; objectId += 1) { + if ( + readBinaryPlistObjectOffset(bytes, objectTable, objectId) === date.offset + ) + dateObjectId = objectId; + } + if (dateObjectId < 0) + throw new Error("binary archive fixture lost its date object-table entry"); + + const crossingOffset = objectTable.tableOffset - 1; + bytes[crossingOffset] = 0x33; + writeBinaryPlistObjectOffset( + bytes, + objectTable, + dateObjectId, + crossingOffset, + ); + return bytes; +}; + +describe("keyed archive scalar projection", () => { + it("preserves the exact integer and unknown date through archive projection", () => { + const source = Buffer.from( + buildBinary({ + ...archive, + $objects: ["$null", Buffer.alloc(16), new Date(0)], + $top: { root: { UID: 1 } }, + }), + ); + const { bytes, integerDecimal } = + embedSubmillisecondDateInWideIntegerPayload(source); + + const graph = decodeKeyedArchiveBytes(bytes, { + root: "root", + offset: 1, + limit: 2, + }); + + expect(graph.objects).toContainEqual( + expect.objectContaining({ + id: 1, + value: { $plist_type: "integer", decimal: integerDecimal }, + }), + ); + expect(graph.objects).toContainEqual( + expect.objectContaining({ + id: 2, + value: { $plist_type: "date", iso8601: null }, + }), + ); + }); + + it("rejects a reachable date whose payload enters the offset table", () => { + expect(() => + decodeKeyedArchiveBytes(binaryArchiveDateCrossingOffsetTable(), { + root: "root", + offset: 0, + limit: 2, + }), + ).toThrow( + expect.objectContaining({ + message: "binary plist date object is truncated", + }), + ); + }); + + it("keeps overlapping date precision unknown without changing its sibling object", () => { + const graph = decodeKeyedArchiveBytes(binaryArchiveWithOverlappingDate(), { + root: "root", + offset: 0, + limit: 4, + }); + + expect(graph.objects).toContainEqual( + expect.objectContaining({ + id: 1, + value: { $plist_type: "date", iso8601: null }, + }), + ); + expect(graph.objects).toContainEqual( + expect.objectContaining({ + id: 2, + value: { $plist_type: "data", base64: "" }, + }), + ); + expect(graph.limitations).toContainEqual( + expect.stringContaining("sub-millisecond source records overlap"), + ); + }); +}); + describe("inert keyed archive decoding", () => { it.each(["bplist00", "NIBArchive"])( "rejects high-bit mutations of %s as malformed bytes rather than another archive kind", diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 3c5379f9ee..0857cbd183 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -7,6 +7,7 @@ import { AnalysisUnsupportedTargetError, } from "../../domain/analysisErrorCore.js"; import { + binaryDatePrecisionOverlapLimitation, negativeZeroRealLimitation, nonFiniteRealLimitation, unknownDateLimitation, @@ -14,9 +15,9 @@ import { } from "../../domain/apple/plistValue.js"; import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js"; import { - decodeBinaryPlistNumbers, - scanBinaryPlistNumberLiterals, -} from "./PlistNumberLiterals.js"; + decodeBinaryPlistScalars, + scanBinaryPlistScalars, +} from "./PlistScalars.js"; import { omittedPrototypeKeysLimitation, parseXmlPropertyList, @@ -36,7 +37,7 @@ import { hasBinaryPlistSignature, hasNibArchiveSignature, } from "../../domain/apple/appleArchiveFormat.js"; -import { estimateBinaryPlistExpansion } from "./InterfaceBuilderDecodeBudget.js"; +import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js"; const MAX_BYTES = 64 * 1024 * 1024; /** Decoded representation budget, the same 8:1 ratio Interface Builder uses. */ @@ -72,20 +73,20 @@ export const decodeKeyedArchiveBytes = ( numberProjection: true, }) : undefined; - const binaryNumberMetadata = binary - ? scanBinaryPlistNumberLiterals(bytes) + const binaryScalarMetadata = binary + ? scanBinaryPlistScalars(bytes, binaryExpansion) : undefined; const parsed = binaryExpansion !== undefined ? { value: parseBinary( - decodeBinaryPlistNumbers( + decodeBinaryPlistScalars( bytes, - binaryNumberMetadata!, - binaryExpansion.binaryDatePayloadOffsets, + binaryScalarMetadata!, + binaryExpansion!, ), ), - numberLiterals: binaryNumberMetadata!.literals, + numberLiterals: binaryScalarMetadata!.literals, omittedPrototypeKeys: binaryExpansion.binaryPrototypeKeyObserved ? null : 0, @@ -131,6 +132,14 @@ export const decodeKeyedArchiveBytes = ( ...(unknownDateCount === 0 ? [] : [unknownDateLimitation(unknownDateCount)]), + ...(binaryExpansion === undefined || + binaryExpansion.datePrecisionOverlapCount === 0 + ? [] + : [ + binaryDatePrecisionOverlapLimitation( + binaryExpansion.datePrecisionOverlapCount, + ), + ]), ...(unknownScalarCount === 0 ? [] : [unknownScalarLimitation(unknownScalarCount)]), diff --git a/src/artifacts/apple/PlistNumberLiterals.test.ts b/src/artifacts/apple/PlistNumberLiterals.test.ts deleted file mode 100644 index 3de13f51ca..0000000000 --- a/src/artifacts/apple/PlistNumberLiterals.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js"; -import { - decodeBinaryPlistNumbers, - scanBinaryPlistNumberLiterals, -} from "./PlistNumberLiterals.js"; - -const dataObjectWithUnusedOverlappingInteger = (): Buffer => { - const bytes = Buffer.alloc(66); - Buffer.from("bplist00", "ascii").copy(bytes); - bytes[8] = 0xa1; // One-element array referencing data object 1. - bytes[9] = 1; - bytes[10] = 0x4f; // Data object with an extended length of 18 bytes. - bytes[11] = 0x10; - bytes[12] = 18; - bytes[13] = 0x14; // An unused object-table entry points into this data. - bytes.fill(0xff, 14, 30); - bytes[30] = 0; - bytes.set([8, 10, 13], 31); // object offsets, including the overlapping entry - bytes[40] = 1; // offset width - bytes[41] = 1; // reference width - bytes.writeBigUInt64BE(3n, 42); - bytes.writeBigUInt64BE(0n, 50); - bytes.writeBigUInt64BE(31n, 58); - return bytes; -}; - -describe("binary plist number rewriting", () => { - it("does not rewrite an integer marker inside another object's data", () => { - const bytes = dataObjectWithUnusedOverlappingInteger(); - const metadata = scanBinaryPlistNumberLiterals(bytes); - - expect(metadata.literals.incomplete).toBe(true); - expect(metadata.wideIntegers).toEqual([]); - expect(decodeBinaryPlistNumbers(bytes, metadata)).toBe(bytes); - expect(bytes.subarray(13, 30)).toEqual( - Buffer.from([0x14, ...Array(16).fill(0xff)]), - ); - }); - - it("applies wide-integer and date rewrites on one copy", () => { - const bytes = Buffer.alloc(34); - bytes[0] = 0x14; - bytes.writeBigUInt64BE(1n, 1); - bytes.writeBigUInt64BE(2n, 9); - bytes.writeDoubleBE(0.1, 18); - bytes.writeDoubleBE(0.1005, 26); - const original = Buffer.from(bytes); - const literals: PlistNumberLiterals = { - integers: new Map(), - reals: new Set(), - unsafeIntegerLiterals: 1, - incomplete: false, - }; - const value = (1n << 64n) | 2n; - const decoded = decodeBinaryPlistNumbers( - bytes, - { literals, wideIntegers: [{ offset: 0, value }] }, - [18, 26], - ); - expect(decoded).not.toBe(bytes); - expect(bytes).toEqual(original); - expect(decoded[0]).toBe(0x23); - expect(decoded.readDoubleBE(1)).toBe(Number(value)); - expect(decoded.readDoubleBE(18)).toBe(0.1); - expect(Number.isNaN(decoded.readDoubleBE(26))).toBe(true); - const representableOnly = { - literals: { ...literals, unsafeIntegerLiterals: 0 }, - wideIntegers: [], - }; - expect(decodeBinaryPlistNumbers(bytes, representableOnly, [18])).toBe( - bytes, - ); - expect(decodeBinaryPlistNumbers(bytes, representableOnly, [])).toBe(bytes); - }); -}); diff --git a/src/artifacts/apple/PlistNumberLiterals.ts b/src/artifacts/apple/PlistNumberLiterals.ts deleted file mode 100644 index 33572a9fa8..0000000000 --- a/src/artifacts/apple/PlistNumberLiterals.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { - createPlistNumberCollector, - type PlistNumberLiterals, -} from "../../domain/apple/plistNumbers.js"; -import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js"; -import { binaryPlistDateRepresentable } from "./BinaryPlistDate.js"; - -export interface BinaryPlistNumberMetadata { - readonly literals: PlistNumberLiterals; - /** 16-byte integers the pinned plist decoder cannot decode correctly. */ - readonly wideIntegers: readonly { offset: number; value: bigint }[]; -} - -const binaryObjectEnd = ( - bytes: Buffer, - offset: number, - objectEnd: number, - referenceWidth: number, -): number | undefined => { - const marker = bytes[offset]; - if (marker === undefined) return undefined; - const type = marker >> 4; - let length = marker & 0x0f; - let cursor = offset + 1; - if (length === 0x0f && type !== 0 && type !== 8) { - const extendedMarker = bytes[cursor]; - if (extendedMarker === undefined || extendedMarker >> 4 !== 1) - return undefined; - const width = 1 << (extendedMarker & 0x0f); - if (width > 8 || cursor + 1 + width > objectEnd) return undefined; - cursor += 1; - let extendedLength = 0n; - for (let index = 0; index < width; index += 1) - extendedLength = - (extendedLength << 8n) | BigInt(bytes[cursor + index] ?? 0); - if (extendedLength > BigInt(Number.MAX_SAFE_INTEGER)) return undefined; - length = Number(extendedLength); - cursor += width; - } - let payloadLength: number; - if (type === 0) payloadLength = 0; - else if (type === 1 || type === 2) { - if ((marker & 0x0f) > 4) return undefined; - payloadLength = 1 << (marker & 0x0f); - } else if (type === 3) { - if (marker !== 0x33) return undefined; - payloadLength = 8; - } else if (type === 4 || type === 5 || type === 7) { - payloadLength = length; - } else if (type === 6) { - payloadLength = length * 2; - } else if (type === 8) { - payloadLength = (marker & 0x0f) + 1; - } else if (type === 10 || type === 11 || type === 12) { - payloadLength = length * referenceWidth; - } else if (type === 13) { - payloadLength = length * 2 * referenceWidth; - } else return undefined; - const end = cursor + payloadLength; - return Number.isSafeInteger(end) && end <= objectEnd ? end : undefined; -}; - -/** Observe bounded binary number markers without changing primary decode acceptance. */ -export const scanBinaryPlistNumberLiterals = ( - bytes: Buffer, -): BinaryPlistNumberMetadata => { - const output = createPlistNumberCollector(); - const wideIntegers: { offset: number; value: bigint }[] = []; - if (bytes.length < 40 || !hasBinaryPlistSignature(bytes)) - return { literals: output.result(true), wideIntegers }; - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const trailer = bytes.length - 32; - const offsetWidth = bytes[trailer + 6] ?? 0; - if (![1, 2, 4, 8].includes(offsetWidth)) - return { literals: output.result(true), wideIntegers }; - const countRaw = view.getBigUint64(trailer + 8); - const tableRaw = view.getBigUint64(trailer + 24); - const safeMaximum = BigInt(Number.MAX_SAFE_INTEGER); - if (countRaw === 0n || countRaw > safeMaximum || tableRaw > safeMaximum) - return { literals: output.result(true), wideIntegers }; - if (tableRaw < 8n || tableRaw >= BigInt(trailer)) - return { literals: output.result(true), wideIntegers }; - const offsetTable = Number(tableRaw); - const capacity = Math.floor((trailer - offsetTable) / offsetWidth); - if (countRaw > BigInt(capacity)) - return { literals: output.result(true), wideIntegers }; - const count = Number(countRaw); - const referenceWidth = bytes[trailer + 7] ?? 0; - if (![1, 2, 4, 8].includes(referenceWidth)) - return { literals: output.result(true), wideIntegers }; - const unsigned = (offset: number): number | undefined => { - if (offset < offsetTable || offset + offsetWidth > trailer) - return undefined; - if (offsetWidth === 1) return view.getUint8(offset); - if (offsetWidth === 2) return view.getUint16(offset); - if (offsetWidth === 4) return view.getUint32(offset); - const value = view.getBigUint64(offset); - return value > safeMaximum ? undefined : Number(value); - }; - const objectOffsets: number[] = []; - for (let index = 0; index < count; index += 1) { - const offset = unsigned(offsetTable + index * offsetWidth); - if (offset === undefined || offset < 8 || offset >= offsetTable) - return { literals: output.result(true), wideIntegers }; - objectOffsets.push(offset); - } - const sortedOffsets = [...new Set(objectOffsets)].sort((a, b) => a - b); - for (let index = 0; index < sortedOffsets.length; index += 1) { - const offset = sortedOffsets[index]; - if (offset === undefined) continue; - const nextOffset = sortedOffsets[index + 1] ?? offsetTable; - if ( - binaryObjectEnd(bytes, offset, nextOffset, referenceWidth) === undefined - ) - return { literals: output.result(true), wideIntegers: [] }; - } - for (const offset of objectOffsets) { - const marker = bytes[offset] ?? 0; - const type = marker >> 4; - if (type !== 1 && type !== 2) continue; - const width = 2 ** (marker & 0xf); - if (offset + 1 + width > offsetTable) - return { literals: output.result(true), wideIntegers }; - if (type === 1 && width === 8) output.integer(view.getBigInt64(offset + 1)); - else if (type === 1 && width === 16) { - const value = BigInt.asIntN( - 128, - (view.getBigUint64(offset + 1) << 64n) | view.getBigUint64(offset + 9), - ); - output.integer(value); - wideIntegers.push({ offset, value }); - } else if (type === 1 && width > 4) - return { literals: output.result(true), wideIntegers }; - else if (type === 2 && width === 4) - output.real(view.getFloat32(offset + 1)); - else if (type === 2 && width === 8) - output.real(view.getFloat64(offset + 1)); - else if (type === 2) return { literals: output.result(true), wideIntegers }; - } - return { literals: output.result(), wideIntegers }; -}; - -/** - * Preserve 16-byte integers and unrepresentable dates through plist@5. - * Both rewrites share one decoder-input copy. The caller's buffer stays - * unchanged, and it is returned when neither rewrite is needed. - * Date seconds are read from the original bytes before any rewrite. - */ -export const decodeBinaryPlistNumbers = ( - bytes: Buffer, - metadata: BinaryPlistNumberMetadata, - datePayloadOffsets: readonly number[] = [], -): Buffer => { - const unrepresentableDates: number[] = []; - for (const offset of datePayloadOffsets) { - if ( - !Number.isSafeInteger(offset) || - offset < 0 || - offset + 8 > bytes.length - ) - throw new TypeError("binary plist date object is truncated"); - if (!binaryPlistDateRepresentable(bytes.readDoubleBE(offset))) - unrepresentableDates.push(offset); - } - if (metadata.wideIntegers.length === 0 && unrepresentableDates.length === 0) - return bytes; - const compatible = Buffer.from(bytes); - for (const { offset, value } of metadata.wideIntegers) { - // Convert only the decoder's input copy to a double. Exact integer metadata - // from the original bytes restores decimal text after the structural parse. - compatible[offset] = 0x23; - compatible.writeDoubleBE(Number(value), offset + 1); - } - for (const offset of unrepresentableDates) - compatible.writeDoubleBE(Number.NaN, offset); - return compatible; -}; diff --git a/src/artifacts/apple/PlistScalars.test.ts b/src/artifacts/apple/PlistScalars.test.ts new file mode 100644 index 0000000000..bf280afec0 --- /dev/null +++ b/src/artifacts/apple/PlistScalars.test.ts @@ -0,0 +1,386 @@ +import { binaryPlistObjectTable } from "../../../tests/fixtures/binaryPlistObjectTable.js"; +import { describe, expect, it } from "vitest"; +import { parseBinary } from "plist"; + +import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js"; +import { projectPlistValue } from "../../domain/apple/plistValue.js"; +import { + binaryDateRequiresExactSeconds, + decodeBinaryPlistScalars, + scanBinaryPlistScalars, +} from "./PlistScalars.js"; +import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js"; + +const dataObjectWithUnusedOverlappingInteger = (): Buffer => { + const bytes = Buffer.alloc(66); + bytes[8] = 0xa1; // One-element array referencing data object 1. + bytes[9] = 1; + bytes[10] = 0x4f; // Data object with an extended length of 18 bytes. + bytes[11] = 0x10; + bytes[12] = 18; + bytes[13] = 0x14; // An unused object-table entry points into this data. + bytes.fill(0xff, 14, 30); + bytes[30] = 0; + return binaryPlistObjectTable(bytes, [8, 10, 13], 31); +}; + +const integerPayloadContainingDate = (): Buffer => { + const bytes = Buffer.alloc(63); + bytes.set([0xa2, 1, 2, 0x14], 8); + bytes.fill(0, 12, 28); + bytes[18] = 0x33; // Reachable date marker aliases the 128-bit integer payload. + bytes.writeDoubleBE(0.1005, 19); + return binaryPlistObjectTable(bytes, [8, 11, 18], 28); +}; + +const integerPayloadContainingReal = (): Buffer => { + const bytes = Buffer.alloc(57); + Buffer.from("bplist00", "ascii").copy(bytes); + bytes.set([0xa2, 1, 2, 0x13], 8); + bytes[12] = 0x23; // Reachable real marker aliases the int64 payload. + bytes.writeDoubleBE(1, 13); + bytes.set([8, 11, 12], 22); + const trailer = bytes.length - 32; + bytes[trailer + 6] = 1; + bytes[trailer + 7] = 1; + bytes.writeBigUInt64BE(3n, trailer + 8); + bytes.writeBigUInt64BE(0n, trailer + 16); + bytes.writeBigUInt64BE(22n, trailer + 24); + return bytes; +}; + +const roundedIntegerAndReal = (): Buffer => { + const bytes = Buffer.alloc(64); + Buffer.from("bplist00", "ascii").copy(bytes); + bytes.set([0xa2, 1, 2, 0x13], 8); + bytes.writeBigInt64BE(9007199254740993n, 12); + bytes[20] = 0x23; + bytes.writeDoubleBE(9007199254740992, 21); + bytes.set([8, 11, 20], 29); + const trailer = bytes.length - 32; + bytes[trailer + 6] = 1; + bytes[trailer + 7] = 1; + bytes.writeBigUInt64BE(3n, trailer + 8); + bytes.writeBigUInt64BE(0n, trailer + 16); + bytes.writeBigUInt64BE(29n, trailer + 24); + return bytes; +}; + +const unreachableOverlappingIntegerAndReal = (): Buffer => { + const bytes = Buffer.alloc(78); + Buffer.from("bplist00", "ascii").copy(bytes); + bytes.set([0xa2, 1, 3, 0x4f, 0x10, 18, 0x13], 8); + bytes.writeBigInt64BE(9007199254740993n, 15); + bytes[32] = 0x23; + bytes.writeDoubleBE(9007199254740992, 33); + bytes.set([8, 11, 14, 32], 42); + const trailer = bytes.length - 32; + bytes[trailer + 6] = 1; + bytes[trailer + 7] = 1; + bytes.writeBigUInt64BE(4n, trailer + 8); + bytes.writeBigUInt64BE(0n, trailer + 16); + bytes.writeBigUInt64BE(42n, trailer + 24); + return bytes; +}; + +const dateInsideDataWithLargeOffsetTable = (): Buffer => { + const bytes = Buffer.alloc(295); + bytes.set([0xa2, 1, 2, 0x4a, 0], 8); + bytes[13] = 0x33; + bytes.writeDoubleBE(0.1005, 14); + return binaryPlistObjectTable(bytes, [8, 11, 13], 260); +}; + +const scalarCrossingOffsetTable = (marker: number, payload: Buffer): Buffer => { + const offsetTable = 14; + const trailer = offsetTable + 1; + const bytes = Buffer.alloc(trailer + 32); + Buffer.from("bplist00", "ascii").copy(bytes); + bytes[8] = marker; + payload.copy(bytes, 9); + bytes[offsetTable] = 8; + bytes[trailer + 6] = 1; + bytes[trailer + 7] = 1; + bytes.writeBigUInt64BE(1n, trailer + 8); + bytes.writeBigUInt64BE(0n, trailer + 16); + bytes.writeBigUInt64BE(BigInt(offsetTable), trailer + 24); + return bytes; +}; + +const scalarStartingAtBoundary = ( + marker: number, + boundary: "offset table" | "trailer", +): Buffer => { + const scalarOffset = marker; + const offsetTable = + boundary === "offset table" ? scalarOffset : scalarOffset - 1; + const trailer = offsetTable + 1; + const bytes = Buffer.alloc(trailer + 32); + Buffer.from("bplist00", "ascii").copy(bytes); + bytes[offsetTable] = scalarOffset; + bytes[scalarOffset] = marker; + bytes[trailer + 6] = 1; + bytes[trailer + 7] = 1; + bytes.writeBigUInt64BE(1n, trailer + 8); + bytes.writeBigUInt64BE(0n, trailer + 16); + bytes.writeBigUInt64BE(BigInt(offsetTable), trailer + 24); + return bytes; +}; + +describe("binary plist date precision", () => { + it("distinguishes canonical milliseconds from nearby source instants", () => { + const next = Buffer.alloc(8); + next.writeDoubleBE(0.1); + next.writeBigUInt64BE(next.readBigUInt64BE(0) + 1n); + for (const seconds of [0, -0, 0.001, 0.1]) + expect(binaryDateRequiresExactSeconds(seconds)).toBe(false); + for (const seconds of [0.1004, 0.1005, next.readDoubleBE(0)]) + expect(binaryDateRequiresExactSeconds(seconds)).toBe(true); + }); + + it.each([ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + 1e20, + ])( + "keeps an invalid or out-of-range date (%s) explicitly unknown", + (seconds) => { + const bytes = Buffer.alloc(50); + bytes[8] = 0x33; + bytes.writeDoubleBE(seconds, 9); + binaryPlistObjectTable(bytes, [8], 17); + const plan = estimateBinaryPlistExpansion(bytes, 1024 * 1024, { + numberProjection: true, + }); + const metadata = scanBinaryPlistScalars(bytes, plan); + expect(binaryDateRequiresExactSeconds(seconds)).toBe(false); + const decoded = parseBinary( + decodeBinaryPlistScalars(bytes, metadata, plan), + ); + expect(projectPlistValue(decoded).value).toEqual({ + $plist_type: "date", + iso8601: null, + }); + }, + ); +}); + +describe("binary plist scalar bounds", () => { + it("validates without retaining scalar plans when projection is not requested", () => { + const bytes = integerPayloadContainingDate(); + const plan = estimateBinaryPlistExpansion(bytes, 1024 * 1024); + expect(plan).toMatchObject({ + datePrecisionOverlapCount: 0, + reachableDateOffsets: [], + overlappingDateOffsets: [], + reachableWideIntegerOffsets: [], + overlappingWideIntegerOffsets: [], + reachableNumericOffsets: [], + }); + expect( + estimateBinaryPlistExpansion(bytes, plan.estimatedBytes).estimatedBytes, + ).toBe(plan.estimatedBytes); + const malformed = Buffer.from(bytes); + malformed[18] = 0x30; + expect(() => estimateBinaryPlistExpansion(malformed, 1024 * 1024)).toThrow( + /date marker/u, + ); + }); + + it.each([ + ["date", 0x33, "3f00000000080000"], + ["integer", 0x13, "0020000000080000"], + ["real", 0x23, "3ff0000000080000"], + ])( + "rejects %s payloads that cross the offset table", + (_kind, marker, hex) => { + const bytes = scalarCrossingOffsetTable(marker, Buffer.from(hex, "hex")); + + expect(() => + estimateBinaryPlistExpansion(bytes, 1024 * 1024, { + bounds: "decoder", + numberProjection: true, + }), + ).toThrow(/truncated/u); + }, + ); + + it.each([ + ["date", 0x33], + ["integer", 0x13], + ["real", 0x23], + ])( + "rejects %s records starting in the offset table or trailer", + (_kind, marker) => { + for (const boundary of ["offset table", "trailer"] as const) { + const bytes = scalarStartingAtBoundary(marker, boundary); + + expect(() => + estimateBinaryPlistExpansion(bytes, 1024 * 1024, { + bounds: "decoder", + numberProjection: true, + }), + ).toThrow(/truncated/u); + } + }, + ); +}); + +describe("binary plist number rewriting", () => { + it("does not rewrite an integer marker inside another object's data", () => { + const bytes = dataObjectWithUnusedOverlappingInteger(); + const metadata = scanBinaryPlistScalars(bytes); + + expect(metadata.literals.incomplete).toBe(false); + expect(metadata.wideIntegers).toEqual([{ offset: 13, value: -1n }]); + expect( + decodeBinaryPlistScalars(bytes, metadata, { + reachableDateOffsets: [], + overlappingDateOffsets: [], + reachableWideIntegerOffsets: [], + overlappingWideIntegerOffsets: [], + reachableNumericOffsets: [], + }), + ).toBe(bytes); + expect(bytes.subarray(13, 30)).toEqual( + Buffer.from([0x14, ...Array(16).fill(0xff)]), + ); + }); + + it("does not rewrite a wide integer when a date aliases its payload", () => { + const bytes = integerPayloadContainingDate(); + const original = Buffer.from(bytes); + const plan = estimateBinaryPlistExpansion(bytes, 1024 * 1024, { + numberProjection: true, + }); + const metadata = scanBinaryPlistScalars(bytes, plan); + + expect(metadata.literals.incomplete).toBe(false); + expect(metadata.wideIntegers).toEqual([ + { offset: 11, value: 242016218615821083930112n }, + ]); + expect(metadata.dates).toEqual([ + { offset: 18, payloadOffset: 19, secondsSince2001: 0.1005 }, + ]); + expect(plan.reachableWideIntegerOffsets).toEqual([11]); + expect(plan.overlappingWideIntegerOffsets).toEqual([11]); + expect(plan.overlappingDateOffsets).toEqual([18]); + expect(() => + estimateBinaryPlistExpansion(bytes, plan.estimatedBytes - 1, { + numberProjection: true, + }), + ).toThrow(/budget/u); + const decoded = decodeBinaryPlistScalars(bytes, metadata, plan); + expect(decoded).not.toBe(bytes); + expect(decoded.readDoubleBE(29)).toBeNaN(); + const parsed = projectPlistValue(parseBinary(decoded)); + expect( + createPlistNumberProjection(metadata.literals).project(parsed.value), + ).toEqual([ + { + $plist_type: "integer", + decimal: "242016218615821083930112", + }, + { $plist_type: "date", iso8601: null }, + ]); + expect(bytes).toEqual(original); + expect(bytes[11]).toBe(0x14); + expect(bytes[18]).toBe(0x33); + }); +}); + +describe("binary plist numeric association", () => { + it("keeps an overlapping int64 exact without rewriting either object", () => { + const bytes = integerPayloadContainingReal(); + const plan = estimateBinaryPlistExpansion(bytes, 1024 * 1024, { + numberProjection: true, + }); + const metadata = scanBinaryPlistScalars(bytes, plan); + + expect(metadata.literals.incomplete).toBe(false); + expect(metadata.literals.integers.get(2540012597650915300)).toEqual( + new Set(["2540012597650915328"]), + ); + const decoded = decodeBinaryPlistScalars(bytes, metadata, plan); + expect(decoded).toBe(bytes); + const parsed = projectPlistValue(parseBinary(decoded)); + expect( + createPlistNumberProjection(metadata.literals).project(parsed.value), + ).toEqual([{ $plist_type: "integer", decimal: "2540012597650915328" }, 1]); + }); + + it("keeps an integral real collision ambiguous after overlap-safe scanning", () => { + const bytes = roundedIntegerAndReal(); + const metadata = scanBinaryPlistScalars(bytes); + expect(metadata.literals.incomplete).toBe(false); + + const parsed = projectPlistValue(parseBinary(bytes)); + const numbers = createPlistNumberProjection(metadata.literals); + expect(numbers.project(parsed.value)).toEqual([ + 9007199254740992, 9007199254740992, + ]); + expect(numbers.limitations()).toEqual([ + '1 observed integer literal(s) in the original archive exceed the exact range of a JSON number. Decoded numbers may lose integer precision unless represented as { "$plist_type": "integer", "decimal": "" }; the original archive bytes retain those literals.', + "2 number(s) beyond the exact integer range remain as decoded because their source integer/real type or reference marker cannot be associated unambiguously; integer precision loss remains possible.", + ]); + }); + + it("does not associate unreachable overlapping integers with a reachable real", () => { + const bytes = unreachableOverlappingIntegerAndReal(); + const plan = estimateBinaryPlistExpansion(bytes, 1024 * 1024, { + numberProjection: true, + }); + const sourceMetadata = scanBinaryPlistScalars(bytes); + const metadata = scanBinaryPlistScalars(bytes, plan); + + expect(sourceMetadata.literals.integers.get(9007199254740992)).toEqual( + new Set(["9007199254740993"]), + ); + expect(metadata.literals.integers.has(9007199254740992)).toBe(false); + expect(metadata.literals.unsafeIntegerLiterals).toBe(0); + const decoded = decodeBinaryPlistScalars(bytes, metadata, plan); + expect(decoded).toBe(bytes); + const parsed = projectPlistValue(parseBinary(decoded)); + expect( + createPlistNumberProjection(metadata.literals).project(parsed.value), + ).toEqual([ + { + $plist_type: "data", + base64: "EwAgAAAAAAABAAAAAAAAAAAA", + }, + 9007199254740992, + ]); + }); +}); + +describe("binary plist scalar clone budgeting", () => { + it("widens offset entries and budgets isolated date clones before allocation", () => { + const bytes = dateInsideDataWithLargeOffsetTable(); + const plan = estimateBinaryPlistExpansion(bytes, 1024 * 1024, { + numberProjection: true, + }); + expect(plan.overlappingDateOffsets).toEqual([13]); + expect(plan.datePrecisionOverlapCount).toBe(1); + expect(() => + estimateBinaryPlistExpansion(bytes, plan.estimatedBytes - 1, { + numberProjection: true, + }), + ).toThrow(/budget/u); + expect( + estimateBinaryPlistExpansion(bytes, plan.estimatedBytes, { + numberProjection: true, + }).estimatedBytes, + ).toBe(plan.estimatedBytes); + + const metadata = scanBinaryPlistScalars(bytes); + const compatible = decodeBinaryPlistScalars(bytes, metadata, plan); + expect(compatible.length).toBeGreaterThan(bytes.length); + expect(compatible[compatible.length - 26]).toBe(2); + expect(compatible.readBigUInt64BE(compatible.length - 8)).toBeGreaterThan( + 255n, + ); + expect(bytes[13]).toBe(0x33); + expect(bytes.readDoubleBE(14)).toBe(0.1005); + }); +}); diff --git a/src/artifacts/apple/PlistScalars.ts b/src/artifacts/apple/PlistScalars.ts new file mode 100644 index 0000000000..ae25eb3342 --- /dev/null +++ b/src/artifacts/apple/PlistScalars.ts @@ -0,0 +1,378 @@ +import { + createPlistNumberCollector, + type PlistNumberLiterals, +} from "../../domain/apple/plistNumbers.js"; +import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js"; + +export interface BinaryPlistScalarMetadata { + readonly literals: PlistNumberLiterals; + /** 16-byte integers the pinned plist decoder cannot decode correctly. */ + readonly wideIntegers: readonly { offset: number; value: bigint }[]; + /** Valid date objects, retaining their exact source seconds before Date conversion. */ + readonly dates: readonly { + readonly offset: number; + readonly payloadOffset: number; + readonly secondsSince2001: number; + }[]; +} + +const containsSortedOffset = ( + offsets: readonly number[], + target: number, +): boolean => { + let low = 0; + let high = offsets.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + const value = offsets[middle]; + if (value === target) return true; + if (value === undefined || value < target) low = middle + 1; + else high = middle; + } + return false; +}; + +export interface BinaryPlistScalarDecodePlan { + readonly reachableDateOffsets: readonly number[]; + readonly overlappingDateOffsets: readonly number[]; + readonly reachableWideIntegerOffsets: readonly number[]; + readonly overlappingWideIntegerOffsets: readonly number[]; + readonly reachableNumericOffsets: readonly number[]; +} + +const APPLE_EPOCH_MILLISECONDS = 978_307_200_000; + +/** Whether Date's millisecond projection would lose finite, in-range source seconds. */ +export const binaryDateRequiresExactSeconds = ( + secondsSince2001: number, +): boolean => { + if (!Number.isFinite(secondsSince2001)) return false; + const date = new Date(secondsSince2001 * 1000 + APPLE_EPOCH_MILLISECONDS); + return ( + Number.isFinite(date.getTime()) && + (date.getTime() - APPLE_EPOCH_MILLISECONDS) / 1000 !== secondsSince2001 + ); +}; + +/** Return the encoded end of one bounded binary-plist object, if recognized. */ +export const binaryPlistObjectEnd = ( + bytes: Buffer, + offset: number, + objectEnd: number, + referenceWidth: number, +): number | undefined => { + const marker = bytes[offset]; + if (marker === undefined) return undefined; + const type = marker >> 4; + let length = marker & 0x0f; + let cursor = offset + 1; + if (length === 0x0f && type !== 0 && type !== 8) { + const extendedMarker = bytes[cursor]; + if (extendedMarker === undefined || extendedMarker >> 4 !== 1) + return undefined; + const width = 1 << (extendedMarker & 0x0f); + if (width > 8 || cursor + 1 + width > objectEnd) return undefined; + cursor += 1; + let extendedLength = 0n; + for (let index = 0; index < width; index += 1) + extendedLength = + (extendedLength << 8n) | BigInt(bytes[cursor + index] ?? 0); + if (extendedLength > BigInt(Number.MAX_SAFE_INTEGER)) return undefined; + length = Number(extendedLength); + cursor += width; + } + let payloadLength: number; + if (type === 0) payloadLength = 0; + else if (type === 1 || type === 2) { + if ((marker & 0x0f) > 4) return undefined; + payloadLength = 1 << (marker & 0x0f); + } else if (type === 3) { + // Apple's date scalar is exactly type 3, info 3, followed by Float64. + if (marker !== 0x33) return undefined; + payloadLength = 8; + } else if (type === 4 || type === 5 || type === 7) { + payloadLength = length; + } else if (type === 6) { + payloadLength = length * 2; + } else if (type === 8) { + payloadLength = (marker & 0x0f) + 1; + } else if (type === 10 || type === 11 || type === 12) { + payloadLength = length * referenceWidth; + } else if (type === 13) { + payloadLength = length * 2 * referenceWidth; + } else return undefined; + const end = cursor + payloadLength; + return Number.isSafeInteger(end) && end <= objectEnd ? end : undefined; +}; + +/** Observe bounded binary scalar markers before parser conversion loses source facts. */ +export const scanBinaryPlistScalars = ( + bytes: Buffer, + scalarPlan?: BinaryPlistScalarDecodePlan, +): BinaryPlistScalarMetadata => { + const output = createPlistNumberCollector(); + const wideIntegers: { offset: number; value: bigint }[] = []; + const dates: { + offset: number; + payloadOffset: number; + secondsSince2001: number; + }[] = []; + const result = (incomplete: boolean): BinaryPlistScalarMetadata => ({ + literals: output.result(incomplete), + wideIntegers, + dates, + }); + if (bytes.length < 40 || !hasBinaryPlistSignature(bytes)) return result(true); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const trailer = bytes.length - 32; + const offsetWidth = bytes[trailer + 6] ?? 0; + if (![1, 2, 4, 8].includes(offsetWidth)) return result(true); + const countRaw = view.getBigUint64(trailer + 8); + const tableRaw = view.getBigUint64(trailer + 24); + const safeMaximum = BigInt(Number.MAX_SAFE_INTEGER); + if (countRaw === 0n || countRaw > safeMaximum || tableRaw > safeMaximum) + return result(true); + if (tableRaw < 8n || tableRaw >= BigInt(trailer)) return result(true); + const offsetTable = Number(tableRaw); + const capacity = Math.floor((trailer - offsetTable) / offsetWidth); + if (countRaw > BigInt(capacity)) return result(true); + const count = Number(countRaw); + const referenceWidth = bytes[trailer + 7] ?? 0; + if (![1, 2, 4, 8].includes(referenceWidth)) return result(true); + const unsigned = (offset: number): number | undefined => { + if (offset < offsetTable || offset + offsetWidth > trailer) + return undefined; + if (offsetWidth === 1) return view.getUint8(offset); + if (offsetWidth === 2) return view.getUint16(offset); + if (offsetWidth === 4) return view.getUint32(offset); + const value = view.getBigUint64(offset); + return value > safeMaximum ? undefined : Number(value); + }; + const objectOffsets: number[] = []; + let incomplete = false; + for (let index = 0; index < count; index += 1) { + const offset = unsigned(offsetTable + index * offsetWidth); + if (offset === undefined || offset < 8 || offset >= offsetTable) { + incomplete = true; + continue; + } + objectOffsets.push(offset); + } + const sortedOffsets = [...new Set(objectOffsets)].sort((a, b) => a - b); + const validOffsets = new Set(); + for (const offset of sortedOffsets) { + const encodedEnd = binaryPlistObjectEnd( + bytes, + offset, + trailer, + referenceWidth, + ); + if (encodedEnd === undefined) { + incomplete = true; + continue; + } + validOffsets.add(offset); + } + const visitedOffsets = new Set(); + for (const offset of objectOffsets) { + if (!visitedOffsets.has(offset)) { + const marker = bytes[offset] ?? 0; + if ( + marker === 0x33 && + offset + 9 <= offsetTable && + (scalarPlan === undefined || + containsSortedOffset(scalarPlan.reachableDateOffsets, offset)) + ) { + const payloadOffset = offset + 1; + dates.push({ + offset, + payloadOffset, + secondsSince2001: view.getFloat64(payloadOffset), + }); + } + } + if (!validOffsets.has(offset) || visitedOffsets.has(offset)) { + incomplete = true; + continue; + } + visitedOffsets.add(offset); + const marker = bytes[offset] ?? 0; + const type = marker >> 4; + if (type === 3 && marker === 0x33) { + continue; + } + if (type !== 1 && type !== 2) continue; + const width = 2 ** (marker & 0xf); + if (offset + 1 + width > offsetTable) incomplete = true; + if (offset + 1 + width > offsetTable) continue; + const isReachable = + scalarPlan === undefined || + containsSortedOffset(scalarPlan.reachableNumericOffsets, offset); + if (type === 1 && width === 8) { + const value = view.getBigInt64(offset + 1); + if (isReachable) output.integer(value); + } else if (type === 1 && width === 16) { + const value = BigInt.asIntN( + 128, + (view.getBigUint64(offset + 1) << 64n) | view.getBigUint64(offset + 9), + ); + if (isReachable) { + output.integer(value); + if ( + scalarPlan === undefined || + containsSortedOffset(scalarPlan.reachableWideIntegerOffsets, offset) + ) + wideIntegers.push({ offset, value }); + } + } else if (type === 1 && width > 4) { + incomplete = true; + } else if (type === 2 && width === 4) { + const value = view.getFloat32(offset + 1); + if (isReachable) output.real(value); + } else if (type === 2 && width === 8) { + const value = view.getFloat64(offset + 1); + if (isReachable) output.real(value); + } else if (type === 2) { + incomplete = true; + } + } + return result(incomplete); +}; + +/** Preserve source scalars through plist@5's lossy Number and Date parser. */ +export const decodeBinaryPlistScalars = ( + bytes: Buffer, + metadata: BinaryPlistScalarMetadata, + scalarPlan: BinaryPlistScalarDecodePlan, +): Buffer => { + const reachableDateOffsets = new Set(scalarPlan.reachableDateOffsets); + const overlappingDateOffsets = new Set(scalarPlan.overlappingDateOffsets); + const reachableWideIntegerOffsets = new Set( + scalarPlan.reachableWideIntegerOffsets, + ); + const overlappingWideIntegerOffsets = new Set( + scalarPlan.overlappingWideIntegerOffsets, + ); + const unrepresentableDates = metadata.dates.filter( + ({ offset, secondsSince2001 }) => + reachableDateOffsets.has(offset) && + binaryDateRequiresExactSeconds(secondsSince2001), + ); + const reachableWideIntegers = metadata.wideIntegers.filter(({ offset }) => + reachableWideIntegerOffsets.has(offset), + ); + const cloneOffsets = new Set([ + ...unrepresentableDates + .filter(({ offset }) => overlappingDateOffsets.has(offset)) + .map(({ offset }) => offset), + ...reachableWideIntegers + .filter(({ offset }) => overlappingWideIntegerOffsets.has(offset)) + .map(({ offset }) => offset), + ]); + if (reachableWideIntegers.length === 0 && unrepresentableDates.length === 0) + return bytes; + const compatible = + cloneOffsets.size === 0 + ? Buffer.from(bytes) + : cloneOverlappingScalars(bytes, cloneOffsets, metadata); + for (const { offset, value } of reachableWideIntegers) { + if (overlappingWideIntegerOffsets.has(offset)) continue; + // Convert only the decoder's input copy to a double. Exact integer metadata + // from the original bytes restores decimal text after the structural parse. + compatible[offset] = 0x23; + compatible.writeDoubleBE(Number(value), offset + 1); + } + // NaN becomes the existing explicit unknown-date projection. Only the private + // decode copy changes; scanner metadata retains the original Float64 seconds. + for (const { offset, payloadOffset } of unrepresentableDates) { + if (!overlappingDateOffsets.has(offset)) + compatible.writeDoubleBE(Number.NaN, payloadOffset); + } + return compatible; +}; + +const cloneOverlappingScalars = ( + bytes: Buffer, + scalarOffsets: ReadonlySet, + metadata: BinaryPlistScalarMetadata, +): Buffer => { + const trailerOffset = bytes.length - 32; + 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 = value * 256n + BigInt(bytes[offset + index] ?? 0); + return value; + }; + const offsets = Array.from({ length: objectCount }, (_, index) => + readOffset(oldTableOffset + index * offsetSize), + ); + const cloneEntries = [...scalarOffsets]; + const wideIntegersByOffset = new Map( + metadata.wideIntegers.map((entry) => [entry.offset, entry.value]), + ); + const newOffsetSize = [1, 2, 4, 8].find((width) => { + if (width < offsetSize) return false; + const maximum = + width === 8 ? Number.MAX_SAFE_INTEGER : 2 ** (width * 8) - 1; + return cloneEntries.every( + (_, index) => oldTableOffset + index * 9 <= maximum, + ); + }); + if (newOffsetSize === undefined) + throw new RangeError( + "binary plist cloned scalar offset exceeds supported width", + ); + const cloneStart = oldTableOffset; + const cloneBytes = cloneEntries.length * 9; + const newTableOffset = cloneStart + cloneBytes; + const newLength = + bytes.length + cloneBytes + objectCount * (newOffsetSize - offsetSize); + if (!Number.isSafeInteger(newLength)) + throw new RangeError( + "binary plist cloned scalar representation is too large", + ); + const compatible = Buffer.alloc(newLength); + bytes.copy(compatible, 0, 0, oldTableOffset); + const cloneOffsetBySource = new Map(); + cloneEntries.forEach((sourceOffset, index) => { + const cloneOffset = cloneStart + index * 9; + cloneOffsetBySource.set(sourceOffset, BigInt(cloneOffset)); + const wideInteger = wideIntegersByOffset.get(sourceOffset); + if (wideInteger !== undefined) { + compatible[cloneOffset] = 0x23; + compatible.writeDoubleBE(Number(wideInteger), cloneOffset + 1); + } else { + bytes.copy(compatible, cloneOffset, sourceOffset, sourceOffset + 9); + compatible.writeDoubleBE(Number.NaN, cloneOffset + 1); + } + }); + const tableStart = newTableOffset; + const tableEnd = tableStart + objectCount * newOffsetSize; + const oldTableEnd = oldTableOffset + objectCount * offsetSize; + const padding = bytes.subarray(oldTableEnd, trailerOffset); + offsets.forEach((sourceOffset, index) => { + const sourceNumber = + sourceOffset <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(sourceOffset) + : undefined; + let value = + sourceNumber === undefined + ? sourceOffset + : (cloneOffsetBySource.get(sourceNumber) ?? sourceOffset); + for (let byte = newOffsetSize - 1; byte >= 0; byte -= 1) { + compatible[tableStart + index * newOffsetSize + byte] = Number( + value & 0xffn, + ); + value >>= 8n; + } + }); + const trailer = Buffer.from(bytes.subarray(trailerOffset)); + trailer[6] = newOffsetSize; + trailer.writeBigUInt64BE(BigInt(newTableOffset), 24); + padding.copy(compatible, tableEnd); + trailer.copy(compatible, tableEnd + padding.length); + return compatible; +}; diff --git a/src/artifacts/apple/PropertyListDecodeBudget.ts b/src/artifacts/apple/PropertyListDecodeBudget.ts new file mode 100644 index 0000000000..45ca4cbd0a --- /dev/null +++ b/src/artifacts/apple/PropertyListDecodeBudget.ts @@ -0,0 +1,522 @@ +import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js"; +import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js"; +import { + binaryDateRequiresExactSeconds, + binaryPlistObjectEnd, +} from "./PlistScalars.js"; + +/** Preflight the representation created by the pinned plist decoder. */ +export const preflightPropertyListDecode = ( + bytes: Buffer, + maxBytes: number, + xmlText?: string, + options: Pick< + BinaryPlistExpansionOptions, + "numberProjection" | "budget" | "fail" + > = {}, +): PlistDecodePreflight => { + const { + numberProjection = false, + budget = "the property list decode budget", + fail = propertyListDecodeBudgetFailure, + } = options; + const expansion = hasBinaryPlistSignature(bytes) + ? estimateBinaryPlistExpansion(bytes, maxBytes, { + numberProjection, + budget, + fail, + }) + : { + estimatedBytes: estimateXmlPlistExpansion( + bytes, + maxBytes, + xmlText, + numberProjection, + budget, + fail, + ), + binaryPrototypeKeyObserved: false, + datePrecisionOverlapCount: 0, + reachableDateOffsets: [], + overlappingDateOffsets: [], + reachableWideIntegerOffsets: [], + overlappingWideIntegerOffsets: [], + reachableNumericOffsets: [], + }; + if (expansion.estimatedBytes > maxBytes) + throw fail("expansion", `plist representation exceeds ${budget}`); + return expansion; +}; + +const estimateXmlPlistExpansion = ( + bytes: Buffer, + maxBytes: number, + xmlText?: string, + numberProjection = false, + budget = "the property list decode budget", + fail: ( + kind: BinaryPlistExpansionFailure, + message: string, + ) => Error = propertyListDecodeBudgetFailure, +): number => { + const xml = xmlText ?? decodeXmlPlistText(bytes); + const decodedSourceBytes = xml.length * 2; + let elementCount = 0; + // Reserve room for numeric source metadata and the projected attribute copy. + const elementBytes = numberProjection ? 256 : 128; + // Value nesting is bounded by the shared plist parser, which counts plist + // value depth; raw element depth would also count the envelope and keys. + for (const token of xmlPreflightTokens(xml)) { + if (token.doctype) { + // Only a declaration outside opaque text can introduce an internal + // subset. Brackets in quoted external identifiers are literal text. + if (token.internalSubset) + throw fail( + "expansion", + `XML plist internal entity expansion exceeds ${budget}`, + ); + continue; + } + elementCount += 1; + if (decodedSourceBytes * 2 + elementCount * elementBytes > maxBytes) + return maxBytes + 1; + } + return decodedSourceBytes * 2 + elementCount * elementBytes; +}; + +/** Consume each XML span once; malformed tokens cannot restart suffix scans. */ +function* xmlPreflightTokens(xml: string) { + let cursor = 0; + while (cursor < xml.length) { + const start = xml.indexOf("<", cursor); + if (start < 0) return; + const opaqueEnd = xml.startsWith("" + : xml.startsWith("" + : xml.startsWith("" + : undefined; + if (opaqueEnd !== undefined) { + const end = xml.indexOf(opaqueEnd, start + 2); + if (end < 0) return; + cursor = end + opaqueEnd.length; + continue; + } + const doctype = /^") break; + else if (doctype && character === "[") internalSubset = true; + } + if (end === xml.length) return; + cursor = end + 1; + if (doctype || element) yield { doctype, internalSubset }; + } +} + +/** Resource or structural failure found while preflighting a plist. */ +export type BinaryPlistExpansionFailure = "cycle" | "depth" | "expansion"; + +/** Typed failure for shared plist preflight resource limits. */ +export class PropertyListDecodeBudgetFailure extends Error { + constructor( + readonly kind: BinaryPlistExpansionFailure, + message: string, + ) { + super(message); + this.name = "PropertyListDecodeBudgetFailure"; + } +} + +const propertyListDecodeBudgetFailure = ( + kind: BinaryPlistExpansionFailure, + message: string, +): Error => new PropertyListDecodeBudgetFailure(kind, message); + +/** How a caller names and reports binary plist expansion failures. */ +export interface BinaryPlistExpansionOptions { + /** Include original number metadata and exact-integer attribute projection. */ + readonly numberProjection?: boolean; + /** Budget named in failure messages. */ + readonly budget?: string; + /** Error for budget, depth, and reference-cycle failures. */ + readonly fail?: (kind: BinaryPlistExpansionFailure, message: string) => Error; + /** + * Structural bounds: "strict" keeps objects and the offset table before the + * trailer; "decoder" follows the decoder's object-table boundary policy + * while still rejecting structures whose coercion would lose plist facts. + */ + readonly bounds?: "strict" | "decoder"; +} + +/** Preflight size and binary-key observations; XML keys are inspected by decode. */ +export interface PlistDecodePreflight { + 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. */ + readonly reachableDateOffsets: readonly number[]; + /** Precision-unknown date offsets requiring decoder-copy isolation. */ + readonly overlappingDateOffsets: readonly number[]; + /** Unique reachable 16-byte integer records in the selected value graph. */ + readonly reachableWideIntegerOffsets: readonly number[]; + /** Source offsets requiring isolated decoder-compatible scalar records. */ + readonly overlappingWideIntegerOffsets: readonly number[]; + /** Reachable integer and real source records for exact number association. */ + readonly reachableNumericOffsets: readonly number[]; +} + +const PROTOTYPE_KEY = "__proto__"; + +type BinaryObjectSpan = { readonly offset: number; readonly end: number }; + +const findOverlappingScalarOffsets = ( + spans: readonly BinaryObjectSpan[], + scalarOffsets: ReadonlySet, +): Set => { + let maxPriorEnd = -1; + const overlappingOffsets = new Set(); + const activeScalars = new Map(); + for (const span of spans) { + for (const [offset, end] of activeScalars) + if (end <= span.offset) activeScalars.delete(offset); + if (scalarOffsets.has(span.offset)) { + if (maxPriorEnd > span.offset) { + overlappingOffsets.add(span.offset); + for (const activeOffset of activeScalars.keys()) + overlappingOffsets.add(activeOffset); + activeScalars.clear(); + } else activeScalars.set(span.offset, span.end); + } else { + for (const activeOffset of activeScalars.keys()) + overlappingOffsets.add(activeOffset); + activeScalars.clear(); + } + maxPriorEnd = Math.max(maxPriorEnd, span.end); + } + return overlappingOffsets; +}; + +const findExactScalarCloneOffsetWidth = ( + currentWidth: number, + firstCloneOffset: number, + cloneCount: number, +): number | undefined => { + const lastCloneOffset = firstCloneOffset + cloneCount * 9 - 9; + return [currentWidth, 2, 4, 8] + .filter((width) => width >= currentWidth) + .find((width) => + width === 8 + ? lastCloneOffset <= Number.MAX_SAFE_INTEGER + : lastCloneOffset < 2 ** (width * 8), + ); +}; + +/** + * Count every expanded binary-plist reference before calling plist.parseBinary, + * which recurses on each reference and copies shared containers. Malformed + * structure throws TypeError; resource and cycle failures use `fail`. + */ +export const estimateBinaryPlistExpansion = ( + bytes: Buffer, + maxBytes: number, + { + budget = "the property list decode budget", + fail = propertyListDecodeBudgetFailure, + bounds = "strict", + numberProjection = false, + }: BinaryPlistExpansionOptions = {}, +): PlistDecodePreflight => { + if (bytes.length < 40) + throw new TypeError("binary plist trailer is truncated"); + const trailer = bytes.length - 32; + const offsetSize = bytes[trailer + 6] ?? 0; + const referenceSize = bytes[trailer + 7] ?? 0; + if ( + ![1, 2, 4, 8].includes(offsetSize) || + ![1, 2, 4, 8].includes(referenceSize) + ) + throw new TypeError("binary plist trailer integer sizes are invalid"); + const readInteger = (offset: number, size: number): number => { + if (offset < 0 || size < 1 || offset + size > bytes.length) return -1; + let value = 0; + for (let index = 0; index < size; index += 1) + value = value * 256 + (bytes[offset + index] ?? 0); + return Number.isSafeInteger(value) ? value : -1; + }; + const objectCount = readInteger(trailer + 8, 8); + const topObject = readInteger(trailer + 16, 8); + const offsetTable = readInteger(trailer + 24, 8); + const tableEnd = bounds === "strict" ? trailer : bytes.length; + const objectsEnd = bounds === "strict" ? offsetTable : bytes.length; + if ( + objectCount < 1 || + topObject < 0 || + topObject >= objectCount || + offsetTable < 8 || + offsetTable + objectCount * offsetSize > tableEnd + ) + throw new TypeError("binary plist trailer or object table is invalid"); + // Reserve source metadata and reachable-offset indexes for scalar projection. + // Without projection, the traversal retains only structural validation state. + const objectBytes = numberProjection ? 320 : 16; + const expandedNodeBytes = numberProjection ? 224 : 96; + // Scalar projection may require a same-sized parser-compatible input copy + // for wide integers and sub-millisecond dates. Reserve before allocation. + let estimate = + bytes.length * (numberProjection ? 3 : 2) + objectCount * objectBytes; + let binaryPrototypeKeyObserved = false; + if (estimate > maxBytes) + throw fail("expansion", `binary plist object table exceeds ${budget}`); + + const active = new Set(); + const pending: Array<{ + object: number; + depth: number; + exit: boolean; + dictionaryKey: boolean; + }> = [{ object: topObject, depth: 0, exit: false, dictionaryKey: false }]; + let pendingObjectCount = 1; + const reachableObjectOffsets = new Set(); + const reachableDateOffsets = new Set(); + const reachableWideIntegerOffsets = new Set(); + const reachableNumericOffsets = new Set(); + const reachableDateOccurrences: number[] = []; + while (pending.length > 0) { + const entry = pending.pop(); + if (entry === undefined) break; + if (entry.exit) { + active.delete(entry.object); + continue; + } + pendingObjectCount -= 1; + if (entry.depth > 128) + throw fail("depth", `binary plist nesting exceeds ${budget}`); + if (active.has(entry.object)) + throw fail("cycle", `binary plist reference cycle exceeds ${budget}`); + estimate += expandedNodeBytes; + if (estimate > maxBytes) + throw fail( + "expansion", + `binary plist reference expansion exceeds ${budget}`, + ); + const offset = readInteger( + offsetTable + entry.object * offsetSize, + offsetSize, + ); + if (offset < 8 || offset >= objectsEnd) + throw new TypeError("binary plist object offset is invalid"); + if (numberProjection) reachableObjectOffsets.add(offset); + const marker = bytes[offset] ?? 0; + const type = marker >> 4; + if (entry.dictionaryKey && type !== 5 && type !== 6) + throw new TypeError("binary plist dictionary key is not a string"); + if (type === 3) { + if (marker !== 0x33) + throw new TypeError( + `binary plist date marker 0x${marker.toString(16)} is invalid`, + ); + // The offset table and trailer are not part of an encoded object. Keep + // this scalar boundary even when compatibility mode follows decoder + // bounds for other object and reference checks. + if (offset + 9 > offsetTable) + throw new TypeError("binary plist date object is truncated"); + if (numberProjection) { + reachableDateOffsets.add(offset); + reachableDateOccurrences.push(offset); + } + continue; + } + if (type === 1 || type === 2) { + const info = marker & 0x0f; + if (type === 1 && info > 4) + throw new TypeError("binary plist integer width is invalid"); + if (type === 2 && info !== 2 && info !== 3) + throw new TypeError("binary plist real width is invalid"); + const width = 1 << info; + if (offset + 1 + width > offsetTable) + throw new TypeError("binary plist numeric scalar is truncated"); + if (numberProjection) { + reachableNumericOffsets.add(offset); + if (type === 1 && width === 16) reachableWideIntegerOffsets.add(offset); + } + continue; + } + let size = marker & 0x0f; + let cursor = offset + 1; + if (size === 0x0f && type !== 0 && type !== 8) { + const extMarker = bytes[cursor] ?? 0; + if (extMarker >> 4 !== 1 || (extMarker & 0x0f) > 3) + throw new TypeError("binary plist extended object size is invalid"); + cursor += 1; + const integerSize = 1 << (extMarker & 0x0f); + size = readInteger(cursor, integerSize); + cursor += integerSize; + if (size < 0 || cursor > objectsEnd) + throw new TypeError("binary plist extended object size is invalid"); + } + if (type === 4 || type === 5 || type === 6) { + 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( + "expansion", + `binary plist scalar expansion exceeds ${budget}`, + ); + continue; + } + if (type !== 10 && type !== 13) continue; + const childCount = type === 13 ? size * 2 : size; + const refsEnd = cursor + childCount * referenceSize; + if (!Number.isSafeInteger(refsEnd) || refsEnd > objectsEnd) + throw new TypeError("binary plist reference table is truncated"); + const availableNodes = Math.floor( + (maxBytes - estimate) / expandedNodeBytes, + ); + if (childCount > availableNodes - pendingObjectCount) + throw fail( + "expansion", + `binary plist reference expansion exceeds ${budget}`, + ); + active.add(entry.object); + pending.push({ + object: entry.object, + depth: entry.depth, + exit: true, + 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"); + pending.push({ + object: reference, + depth: entry.depth + 1, + exit: false, + dictionaryKey: type === 13 && index < size, + }); + } + pendingObjectCount += childCount; + } + if (!numberProjection) + return { + estimatedBytes: estimate, + binaryPrototypeKeyObserved, + datePrecisionOverlapCount: 0, + reachableDateOffsets: [], + overlappingDateOffsets: [], + reachableWideIntegerOffsets: [], + overlappingWideIntegerOffsets: [], + reachableNumericOffsets: [], + }; + + // Distinct objects can legally overlap in binary plists. Rewriting a date or + // wide integer in place could change another reachable value, so isolate + // affected scalar records in the decoder copy. Track both overlap directions + // using the same bounded span parser as scalar collection. + const reachableSpans = [...reachableObjectOffsets] + .map((offset) => ({ + offset, + end: binaryPlistObjectEnd(bytes, offset, objectsEnd, referenceSize), + })) + .filter( + (span): span is { offset: number; end: number } => span.end !== undefined, + ) + .sort((left, right) => left.offset - right.offset); + const overlappingDateOffsets = findOverlappingScalarOffsets( + reachableSpans, + reachableDateOffsets, + ); + const overlappingWideIntegerOffsets = findOverlappingScalarOffsets( + reachableSpans, + reachableWideIntegerOffsets, + ); + const precisionUnknownDateOffsets = [...overlappingDateOffsets] + .filter((offset) => { + const seconds = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getFloat64(offset + 1); + return binaryDateRequiresExactSeconds(seconds); + }) + .sort((left, right) => left - right); + const cloneOffsets = new Set([ + ...precisionUnknownDateOffsets, + ...overlappingWideIntegerOffsets, + ]); + if (cloneOffsets.size > 0) { + const cloneBytes = cloneOffsets.size * 9; + const newOffsetSize = findExactScalarCloneOffsetWidth( + offsetSize, + offsetTable, + cloneOffsets.size, + ); + if (newOffsetSize === undefined) + throw new TypeError( + "binary plist cloned scalar offset exceeds supported width", + ); + estimate += cloneBytes + objectCount * (newOffsetSize - offsetSize); + if (estimate > maxBytes) + throw fail( + "expansion", + `binary plist scalar-copy expansion exceeds ${budget}`, + ); + } + const precisionUnknownOffsets = new Set(precisionUnknownDateOffsets); + const datePrecisionOverlapCount = reachableDateOccurrences.filter((offset) => + precisionUnknownOffsets.has(offset), + ).length; + return { + estimatedBytes: estimate, + binaryPrototypeKeyObserved, + datePrecisionOverlapCount, + reachableDateOffsets: [...reachableDateOffsets].sort( + (left, right) => left - right, + ), + overlappingDateOffsets: precisionUnknownDateOffsets, + reachableWideIntegerOffsets: [...reachableWideIntegerOffsets].sort( + (left, right) => left - right, + ), + overlappingWideIntegerOffsets: [...overlappingWideIntegerOffsets].sort( + (left, right) => left - right, + ), + reachableNumericOffsets: [...reachableNumericOffsets].sort( + (left, right) => left - right, + ), + }; +}; diff --git a/src/domain/apple/plistValue.ts b/src/domain/apple/plistValue.ts index 6f406cc833..9459f0bbc7 100644 --- a/src/domain/apple/plistValue.ts +++ b/src/domain/apple/plistValue.ts @@ -28,6 +28,10 @@ export interface ProjectedPlistValue { export const unknownDateLimitation = (count: number): string => `${String(count)} date value(s) are reported as { "$plist_type": "date", "iso8601": null } because the decoder could not represent their timestamps.`; +/** Limitation when shared binary bytes prevent safely normalizing date precision. */ +export const binaryDatePrecisionOverlapLimitation = (count: number): string => + `${String(count)} date value(s) are reported with unknown timestamps because their sub-millisecond source records overlap another reachable binary object and the public date projection cannot express their exact instants.`; + /** Limitation for XML scalar literals whose values were not established. */ export const unknownScalarLimitation = (count: number): string => `${String(count)} XML scalar value(s) could not be established; their exact literals and reasons are retained in { "$plist_type": "unknown", "element": ..., "literal": ..., "reason": ... } wrappers.`; diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 3b3b565d01..8c8f9ebc57 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -1,9 +1,12 @@ import { plistSourceFormat, + isBinaryPlistDescription, + readNativePlistBytes, requireRegularPlist, nativePlistFailure, resolveNativePlistSelection, unreadablePlist, + type NativePlistSelection, } from "./NativePlistSource.js"; import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; @@ -50,7 +53,6 @@ import type { EvidenceLocation } from "../domain/evidence.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, - AnalysisInputError, AnalysisOutputError, AnalysisResourceConstraintError, AnalysisTimeoutError, @@ -105,8 +107,10 @@ import { parseDemangledSymbols } from "./parsers/demangle.js"; import { parseLipoOutput } from "./parsers/lipo.js"; import { parsePlistJson, + parsePlistBinary, parsePlistXml, plistJsonNeedsNumberTypes, + type ParsedPlist, } from "./parsers/plist.js"; import { architectureLocations, @@ -128,6 +132,12 @@ type SignatureRuntimeRootFactory = () => Promise< Pick >; +interface NativePlistReadContext { + readonly selection: NativePlistSelection; + readonly resources: ArtifactResourceScope; + readonly signal: AbortSignal | undefined; +} + /** Read-only semantic provider composed from Xcode command-line utilities. */ export class NativeMacOSProvider implements AnalysisProvider { readonly #capabilities: readonly CapabilityDescriptor[]; @@ -887,7 +897,7 @@ class NativeMacOSClient implements AnalysisClient { requested, ); if (!plist.ok) return plist; - const fileInput = { + const fileInput: NativePlistReadContext = { selection: plist.value, resources: this.#plistResources, signal, @@ -901,98 +911,12 @@ class NativeMacOSClient implements AnalysisClient { { signal }, ); if (!classified.ok) return classified; - const json = await this.#run( - "inspect_plist", - "plutil", - ["-convert", "json", "-o", "-", "--", plist.value.path], - { signal, acceptNonZero: true }, - ); - if (!json.ok) return json; - // JSON cannot express data, dates, or non-finite reals; plutil rejects - // such plists, so decode its lossless XML conversion instead. JSON also - // prints an integral like an , so a number beyond the - // exact JSON range takes its element type from the XML conversion. - const jsonDecoded = json.value.exitCode === 0; - const xml = - jsonDecoded && !plistJsonNeedsNumberTypes(json.value.stdout) - ? undefined - : await this.#run( - "inspect_plist", - "plutil", - ["-convert", "xml1", "-o", "-", "--", plist.value.path], - { signal, acceptNonZero: !jsonDecoded }, - ); - if (xml !== undefined && !xml.ok) return xml; - if (xml !== undefined && xml.value.exitCode !== 0) { - const capture = failureCapture(xml.value, false); - return err( - unreadablePlist( - plist.value, - `plutil could not decode it as a property list (${plutilDiagnostic(capture, plist.value.path)})`, - { - capturedOutput: { - stdout: capture.stdout, - stderr: capture.stderr, - truncated: capture.truncated, - stdout_bytes: capture.stdoutBytes, - stderr_bytes: capture.stderrBytes, - exit_code: capture.exitCode, - signal: capture.signal, - }, - }, - ), - ); - } - const parsed = - xml === undefined - ? parsePlistJson(json.value.stdout) - : jsonDecoded - ? parsePlistJson(json.value.stdout, xml.value.stdout) - : parsePlistXml(xml.value.stdout); - if (!parsed.ok) return parsed; - const provenance = [ + const binary = await this.#inspectBinaryPlist( + fileInput, classified.value, - json.value, - ...(xml === undefined ? [] : [xml.value]), - ].map((item) => invocation(item, plist.value.path, "$PLIST")); - const converted = xml?.value ?? json.value; - const format = await plistSourceFormat(classified.value.stdout, { - ...fileInput, - capturedOutput: { - stdout: converted.stdout, - stderr: converted.stderr, - truncated: false, - stdout_bytes: converted.stdoutBytes, - stderr_bytes: converted.stderrBytes, - exit_code: converted.exitCode, - signal: converted.signal, - }, - }); - if (!format.ok) return format; - const result = inspectPlistSchema.parse({ - format: format.value, - value: parsed.value.value, - bundle: parsed.value.bundle, - source_path: plist.value.path, - ...(this.bundleResourceRoot === undefined - ? {} - : { - resource_root: { - caller_path: this.bundleResourceRoot.callerPath, - canonical_path: this.bundleResourceRoot.canonicalPath, - info_plist_path: this.bundleResourceRoot.infoPlistPath, - layout: this.bundleResourceRoot.layout, - executable_sha256: this.bundleResourceRoot.executableSha256, - }, - }), - provenance, - limitations: parsed.value.limitations, - }); - return ok({ - result: jsonValueSchema.parse(result), - limitations: parsed.value.limitations, - locations: [{ kind: "artifact-path", path: plist.value.path }], - }); + ); + if (binary !== undefined) return binary; + return await this.#inspectConvertedPlist(fileInput, classified.value); } catch (cause: unknown) { // Producer failures stay opaque; only scope admission failures reach the file mapper. return err(nativeOutputFailure("inspect_plist", cause)); @@ -1003,6 +927,122 @@ class NativeMacOSClient implements AnalysisClient { } } + async #inspectBinaryPlist( + fileInput: NativePlistReadContext, + classified: NativeCommandCapture, + ): Promise | undefined> { + if (!isBinaryPlistDescription(classified.stdout)) return undefined; + const source = await readNativePlistBytes(fileInput); + if (!source.ok) return source; + const parsed = parsePlistBinary(source.value); + if (!parsed.ok) { + return parsed.error instanceof AnalysisOutputError + ? err( + unreadablePlist( + fileInput.selection, + "binary property list could not be decoded", + { cause: parsed.error }, + ), + ) + : parsed; + } + return ok( + nativePlistObservation({ + parsed: parsed.value, + format: "binary", + path: fileInput.selection.path, + resourceRoot: this.bundleResourceRoot, + provenance: [ + invocation(classified, fileInput.selection.path, "$PLIST"), + ], + }), + ); + } + + async #inspectConvertedPlist( + fileInput: NativePlistReadContext, + classified: NativeCommandCapture, + ): Promise> { + const { path } = fileInput.selection; + const { signal } = fileInput; + const json = await this.#run( + "inspect_plist", + "plutil", + ["-convert", "json", "-o", "-", "--", path], + { signal, acceptNonZero: true }, + ); + if (!json.ok) return json; + // JSON cannot express data, dates, or non-finite reals; plutil rejects + // such plists, so decode its XML conversion instead. JSON also prints an + // integral like an , so XML identifies unsafe number types. + const jsonDecoded = json.value.exitCode === 0; + const xml = + jsonDecoded && !plistJsonNeedsNumberTypes(json.value.stdout) + ? undefined + : await this.#run( + "inspect_plist", + "plutil", + ["-convert", "xml1", "-o", "-", "--", path], + { signal, acceptNonZero: !jsonDecoded }, + ); + if (xml !== undefined && !xml.ok) return xml; + if (xml !== undefined && xml.value.exitCode !== 0) { + const capture = failureCapture(xml.value, false); + return err( + unreadablePlist( + fileInput.selection, + `plutil could not decode it as a property list (${plutilDiagnostic(capture, path)})`, + { + capturedOutput: { + stdout: capture.stdout, + stderr: capture.stderr, + truncated: capture.truncated, + stdout_bytes: capture.stdoutBytes, + stderr_bytes: capture.stderrBytes, + exit_code: capture.exitCode, + signal: capture.signal, + }, + }, + ), + ); + } + const parsed = + xml === undefined + ? parsePlistJson(json.value.stdout) + : jsonDecoded + ? parsePlistJson(json.value.stdout, xml.value.stdout) + : parsePlistXml(xml.value.stdout); + if (!parsed.ok) return parsed; + const provenance = [ + classified, + json.value, + ...(xml === undefined ? [] : [xml.value]), + ].map((item) => invocation(item, path, "$PLIST")); + const converted = xml?.value ?? json.value; + const format = await plistSourceFormat(classified.stdout, { + ...fileInput, + capturedOutput: { + stdout: converted.stdout, + stderr: converted.stderr, + truncated: false, + stdout_bytes: converted.stdoutBytes, + stderr_bytes: converted.stderrBytes, + exit_code: converted.exitCode, + signal: converted.signal, + }, + }); + if (!format.ok) return format; + return ok( + nativePlistObservation({ + parsed: parsed.value, + format: format.value, + path, + resourceRoot: this.bundleResourceRoot, + provenance, + }), + ); + } + async #inspectMacho( signal?: AbortSignal, ): Promise> { @@ -1432,6 +1472,40 @@ const invocation = ( stderr_bytes: capture.stderrBytes, }); +const nativePlistObservation = (input: { + readonly parsed: ParsedPlist; + readonly format: z.infer["format"]; + readonly path: string; + readonly resourceRoot: ResolvedAppBundleResourceRoot | undefined; + readonly provenance: readonly NativeCommandInvocation[]; +}): NativeObservation => { + const { parsed, format, path, resourceRoot, provenance } = input; + const result = inspectPlistSchema.parse({ + format, + value: parsed.value, + bundle: parsed.bundle, + source_path: path, + ...(resourceRoot === undefined + ? {} + : { + resource_root: { + caller_path: resourceRoot.callerPath, + canonical_path: resourceRoot.canonicalPath, + info_plist_path: resourceRoot.infoPlistPath, + layout: resourceRoot.layout, + executable_sha256: resourceRoot.executableSha256, + }, + }), + provenance, + limitations: parsed.limitations, + }); + return { + result: jsonValueSchema.parse(result), + limitations: parsed.limitations, + locations: [{ kind: "artifact-path", path }], + }; +}; + const parseEntitlements = ( output: string, ): { diff --git a/src/native/NativePlistSource.ts b/src/native/NativePlistSource.ts index 8c6f2e5828..6b66a9f345 100644 --- a/src/native/NativePlistSource.ts +++ b/src/native/NativePlistSource.ts @@ -11,9 +11,11 @@ import type { AnalysisCapturedOutput, } from "../domain/analysisErrorBase.js"; import { + AnalysisArtifactChangedError, AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisInputError, + AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -26,10 +28,12 @@ import { artifactFilePrimaryFailure, artifactFileCancellationCause, bindArtifactFileCleanup, + assertArtifactReadActive, withArtifactRegularFile, } from "../artifacts/ArtifactFileOperation.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY } from "./NativeMacOSProviderMetadata.js"; +import { MAX_NATIVE_PLIST_BYTES } from "./parsers/plist.js"; /** Resolved lookup identity and selection intent, independent of file readiness. */ export interface NativePlistSelection { @@ -197,6 +201,29 @@ export const nativePlistFailure = ( cause.message, { cause, capturedOutput }, ); + if ( + selection !== undefined && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + "inspect_plist", + selection.path, + cause.message, + { cause, capturedOutput }, + ); + if (cause instanceof ArtifactReaderFailure && cause.reason === "limit") { + const resource = cause.boundary ?? "memory"; + return new AnalysisResourceConstraintError( + "inspect_plist", + resource, + cause.message, + resource === "file-size" + ? { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES } + : null, + { cause, capturedOutput, retryable: false }, + ); + } if (selection === undefined) return new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause, @@ -263,6 +290,51 @@ export const requireRegularPlist = ( ): Promise> => readPlistFile(input, async () => null); +/** Read bounded source bytes through the same retained descriptor owner. */ +export const readNativePlistBytes = ( + input: PlistFileInput, +): Promise> => + readPlistFile(input, async (file) => { + const before = await file.stat({ bigint: true }); + if (before.size > BigInt(MAX_NATIVE_PLIST_BYTES)) + throw new ArtifactReaderFailure( + "limit", + `Selected plist exceeds ${String(MAX_NATIVE_PLIST_BYTES)} bytes: ${input.selection.path}`, + { boundary: "file-size" }, + ); + const bytes = Buffer.allocUnsafe(Number(before.size)); + let position = 0; + while (position < bytes.length) { + assertArtifactReadActive(input.signal); + const length = Math.min(64 * 1024, bytes.length - position); + const { bytesRead } = await file.read(bytes, position, length, position); + if (bytesRead === 0) break; + position += bytesRead; + } + const after = await file.stat({ bigint: true }); + if (after.size > BigInt(MAX_NATIVE_PLIST_BYTES)) + throw new ArtifactReaderFailure( + "limit", + `Selected plist grew beyond ${String(MAX_NATIVE_PLIST_BYTES)} bytes: ${input.selection.path}`, + { boundary: "file-size" }, + ); + if ( + !after.isFile() || + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs || + BigInt(position) !== before.size + ) + throw new ArtifactReaderFailure( + "integrity", + `Selected plist changed during read: ${input.selection.path}`, + ); + assertArtifactReadActive(input.signal); + return bytes; + }); + /** Bytes examined for leading markup when `file` reports only text. */ const PLIST_MARKUP_PROBE_BYTES = 4096; @@ -273,7 +345,7 @@ export const plistSourceFormat = async ( ): Promise< Result["format"], AnalysisError> > => { - if (/binary property list/iu.test(description)) return ok("binary"); + if (isBinaryPlistDescription(description)) return ok("binary"); if (/\bXML\b/iu.test(description)) return ok("xml"); if (/\bJSON\b/iu.test(description)) return ok("json"); if (!/\btext\b/iu.test(description)) return ok("unknown"); @@ -296,3 +368,6 @@ export const plistSourceFormat = async ( : "unknown"; }); }; + +export const isBinaryPlistDescription = (description: string): boolean => + /binary property list/iu.test(description); diff --git a/src/native/parsers/plist.test.ts b/src/native/parsers/plist.test.ts index 6cfde85b7e..468131ef0c 100644 --- a/src/native/parsers/plist.test.ts +++ b/src/native/parsers/plist.test.ts @@ -1,10 +1,438 @@ import { expect, it } from "vitest"; +import { buildBinary } from "plist"; +import { + decodeBinaryPlistScalars, + scanBinaryPlistScalars, +} from "../../artifacts/apple/PlistScalars.js"; import { + parsePlistBinary, parsePlistJson, parsePlistXml, plistJsonNeedsNumberTypes, } from "./plist.js"; +import { estimateBinaryPlistExpansion } from "../../artifacts/apple/PropertyListDecodeBudget.js"; +import { + binaryPlistObjectTable, + binaryPlistWithOverlappingDateAndData, +} from "../../../tests/fixtures/binaryPlistObjectTable.js"; + +const parseWithoutMutatingSource = (bytes: Buffer) => { + const original = Buffer.from(bytes); + const parsed = parsePlistBinary(bytes); + expect(parsed.ok).toBe(true); + if (!parsed.ok) throw parsed.error; + expect(bytes).toEqual(original); + return parsed.value; +}; + +const binaryPlistWithNegativeZero = (): Buffer => { + const bytes = Buffer.from( + buildBinary({ + negative: -1.25, + positive: 0, + note: "a\u0001b", + payload: Buffer.from([0]), + fraction: new Date("2020-05-05T01:02:03.100Z"), + whole: new Date("2020-05-05T01:02:03.000Z"), + nel: "a\u0085b", + ls: "a\u2028b", + ps: "a\u2029b", + largeInteger: 9007199254740992, + }), + ); + const negativeOneAndQuarter = Buffer.from([ + 0x23, 0xbf, 0xf4, 0, 0, 0, 0, 0, 0, + ]); + const offset = bytes.indexOf(negativeOneAndQuarter); + if (offset < 0) throw new Error("binary plist fixture lost its real"); + bytes.fill(0, offset + 1, offset + 9); + bytes[offset + 1] = 0x80; + const largeReal = Buffer.alloc(9); + largeReal[0] = 0x23; + largeReal.writeDoubleBE(9007199254740992, 1); + const largeOffset = bytes.indexOf(largeReal); + if (largeOffset < 0) throw new Error("binary plist fixture lost its integer"); + bytes[largeOffset] = 0x13; + bytes.writeBigInt64BE(9007199254740992n, largeOffset + 1); + return bytes; +}; + +const binaryDate = (secondsSince2001: number): Buffer => { + const bytes = Buffer.from( + buildBinary({ date: new Date("2001-01-01T00:00:00.000Z") }), + ); + const metadata = scanBinaryPlistScalars(bytes); + const date = metadata.dates[0]; + if (date === undefined) throw new Error("binary plist fixture lost its date"); + bytes.writeDoubleBE(secondsSince2001, date.payloadOffset); + return bytes; +}; + +const binarySharedSubmillisecondDate = (): Buffer => { + // Array object 0 references the same date object 1 twice. + const bytes = Buffer.alloc(66); + bytes[8] = 0xa2; + bytes[9] = 1; + bytes[10] = 1; + bytes[11] = 0x33; + bytes.writeDoubleBE(0.1005, 12); + return binaryPlistObjectTable(bytes, [8, 11], 20); +}; + +const binaryWithUnreachableMalformedDate = (): Buffer => { + const bytes = Buffer.alloc(54); + bytes.set([0x52, 0x6f, 0x6b, 0x30], 8); // Root "ok"; unused malformed date. + return binaryPlistObjectTable(bytes, [8, 11], 20); +}; + +const binaryWithReachableAndUnreachableDate = (): Buffer => { + const bytes = Buffer.alloc(67); + bytes.set([0xa1, 1, 0x33], 8); + bytes.writeDoubleBE(0.1005, 11); + bytes[19] = 0x30; // Unreachable malformed date marker. + return binaryPlistObjectTable(bytes, [8, 10, 19], 20); +}; + +const binaryWithExactOverlappingDateAndNull = (): Buffer => { + const bytes = Buffer.alloc(55); + bytes.set([0xa2, 1, 2, 0x33], 8); + bytes.writeDoubleBE(0, 12); + return binaryPlistObjectTable(bytes, [8, 11, 18], 20); +}; + +const binaryWithDateInsideData = (): Buffer => + Buffer.from( + "62706c6973743030a201024a00333fb9ba5e353f7cee080b0d0000000000000101000000000000000300000000000000000000000000000016", + "hex", + ); + +const binaryWithOverlappingAndIndependentDates = (): Buffer => { + const bytes = Buffer.alloc(68); + bytes.set([0xa3, 1, 2, 3, 0x4a, 0, 0x33], 8); + bytes.writeDoubleBE(0.1005, 15); + bytes.set([0x33], 23); + bytes.writeDoubleBE(0.1, 24); + return binaryPlistObjectTable(bytes, [8, 12, 14, 23], 32); +}; + +const binaryWithOverlappingIntegerDate = (): Buffer => + Buffer.from( + "62706c6973743030a20102333fb999999999102a080b120000000000000101000000000000000300000000000000000000000000000014", + "hex", + ); + +const binaryWithSharedOverlappingDate = (): Buffer => { + const bytes = Buffer.alloc(57); + bytes.set([0xa3, 1, 2, 3, 0x33], 8); + bytes.writeDoubleBE(0.1005, 13); + bytes[20] = 0x40; + return binaryPlistObjectTable(bytes, [8, 12, 12, 20], 21); +}; + +const binaryInt128 = (marker = 0x14): Buffer => { + const bytes = Buffer.alloc(58); + bytes[8] = marker; + bytes.writeBigUInt64BE(0n, 9); + bytes.writeBigUInt64BE(9007199254740993n, 17); + bytes[25] = 8; + return binaryPlistObjectTable(bytes, [8], 25); +}; + +const dateProjection = (secondsSince2001: number) => { + const source = binaryDate(secondsSince2001); + const metadata = scanBinaryPlistScalars(source); + const datePlan = estimateBinaryPlistExpansion(source, 1024 * 1024, { + numberProjection: true, + }); + const decodedInput = decodeBinaryPlistScalars(source, metadata, datePlan); + const parsed = parsePlistBinary(source); + if (!parsed.ok) throw parsed.error; + return { source, decodedInput, metadata, parsed: parsed.value }; +}; + +it.each([ + [0, "2001-01-01T00:00:00.000Z"], + [0.1, "2001-01-01T00:00:00.100Z"], + [-0.1, "2000-12-31T23:59:59.900Z"], + [ + (Date.parse("1900-01-01T00:00:00.900Z") - + Date.parse("2001-01-01T00:00:00.000Z")) / + 1000, + "1900-01-01T00:00:00.900Z", + ], +])( + "preserves binary dates exactly representable in milliseconds: %s", + (seconds, iso) => { + const result = dateProjection(seconds); + expect(result.parsed.value).toEqual({ + date: { $plist_type: "date", iso8601: iso }, + }); + expect(result.decodedInput).toBe(result.source); + }, +); + +it.each([0.1005, -0.1005, 0.0005])( + "marks binary sub-millisecond source seconds unknown rather than rounding: %s", + (seconds) => { + const result = dateProjection(seconds); + expect(result.metadata.dates[0]?.secondsSince2001).toBe(seconds); + expect(result.decodedInput).not.toBe(result.source); + expect( + result.source.readDoubleBE(result.metadata.dates[0]!.payloadOffset), + ).toBe(seconds); + expect(result.parsed.value).toEqual({ + date: { $plist_type: "date", iso8601: null }, + }); + expect(result.parsed.limitations).toContainEqual( + expect.stringContaining("date value(s)"), + ); + }, +); + +it("keeps distinct source dates separate when they round to the same millisecond", () => { + const bytes = Buffer.from( + buildBinary([ + new Date("2001-01-01T00:00:00.100Z"), + new Date("2001-01-01T00:00:00.100Z"), + ]), + ); + const metadata = scanBinaryPlistScalars(bytes); + expect(metadata.dates).toHaveLength(2); + bytes.writeDoubleBE(0.1005, metadata.dates[1]!.payloadOffset); + const parsed = parsePlistBinary(bytes); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.value).toEqual([ + { $plist_type: "date", iso8601: "2001-01-01T00:00:00.100Z" }, + { $plist_type: "date", iso8601: null }, + ]); +}); + +it("projects a shared sub-millisecond date reference as unknown at each use", () => { + const source = binarySharedSubmillisecondDate(); + const metadata = scanBinaryPlistScalars(source); + expect(metadata.dates).toHaveLength(1); + expect(metadata.dates[0]?.secondsSince2001).toBe(0.1005); + const parsed = parsePlistBinary(source); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.value).toEqual([ + { $plist_type: "date", iso8601: null }, + { $plist_type: "date", iso8601: null }, + ]); +}); + +it("retains out-of-range date source seconds without rewriting them", () => { + const result = dateProjection(1e20); + expect(result.metadata.dates[0]?.secondsSince2001).toBe(1e20); + expect(result.decodedInput).toBe(result.source); + expect(result.parsed.value).toEqual({ + date: { $plist_type: "date", iso8601: null }, + }); +}); + +it("does not treat a malformed date marker as a valid date scalar", () => { + const bytes = binaryDate(0.1005); + const valid = scanBinaryPlistScalars(bytes); + bytes[valid.dates[0]!.offset] = 0x30; + const metadata = scanBinaryPlistScalars(bytes); + expect(metadata.literals.incomplete).toBe(true); + expect(metadata.dates).toEqual([]); + expect( + decodeBinaryPlistScalars(bytes, metadata, { + reachableDateOffsets: [], + overlappingDateOffsets: [], + reachableWideIntegerOffsets: [], + overlappingWideIntegerOffsets: [], + reachableNumericOffsets: [], + }), + ).toBe(bytes); +}); + +it("rejects a reachable date with a malformed marker", () => { + const bytes = binaryDate(0.1); + const date = scanBinaryPlistScalars(bytes).dates[0]; + if (date === undefined) throw new Error("binary plist fixture lost its date"); + bytes[date.offset] = 0x30; + + const parsed = parsePlistBinary(bytes); + + expect(parsed.ok).toBe(false); + if (parsed.ok) throw new Error("Expected malformed reachable date rejection"); + expect(parsed.error.message).toContain("date marker 0x30 is invalid"); +}); + +it("does not reject an unreachable malformed date object as a decoded value", () => { + const parsed = parsePlistBinary(binaryWithUnreachableMalformedDate()); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.value).toBe("ok"); + expect(parsed.value.limitations).toContainEqual( + expect.stringContaining("number metadata could not be completely observed"), + ); +}); + +it("keeps reachable date uncertainty when another object is malformed", () => { + const parsed = parsePlistBinary(binaryWithReachableAndUnreachableDate()); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.value).toEqual([{ $plist_type: "date", iso8601: null }]); + expect(parsed.value.limitations).toContainEqual( + expect.stringContaining("number metadata could not be completely observed"), + ); +}); + +it("retains reachable date source facts when an unreachable offset is invalid", () => { + const bytes = binaryWithReachableAndUnreachableDate(); + bytes[22] = 0xff; + const metadata = scanBinaryPlistScalars(bytes); + const parsed = parsePlistBinary(bytes); + + expect(metadata.dates).toHaveLength(1); + expect(metadata.dates[0]?.secondsSince2001).toBe(0.1005); + expect(parsed.ok).toBe(true); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.value).toEqual([{ $plist_type: "date", iso8601: null }]); + expect(parsed.value.limitations).toContainEqual( + expect.stringContaining("number metadata could not be completely observed"), + ); +}); + +it("does not rewrite shared bytes when a reachable date overlaps another object", () => { + const bytes = binaryPlistWithOverlappingDateAndData(); + const parsed = parseWithoutMutatingSource(bytes); + + expect(parsed.value).toEqual([ + { $plist_type: "date", iso8601: null }, + { $plist_type: "data", base64: "" }, + ]); + expect(parsed.limitations).toContainEqual( + expect.stringContaining("sub-millisecond source records overlap"), + ); +}); + +it("keeps an exactly millisecond-representable overlapping date known", () => { + const parsed = parsePlistBinary(binaryWithExactOverlappingDateAndNull()); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.value).toEqual([ + { $plist_type: "date", iso8601: "2001-01-01T00:00:00.000Z" }, + null, + ]); + expect(parsed.value.limitations).not.toContainEqual( + expect.stringContaining("sub-millisecond source records overlap"), + ); +}); + +it("keeps a date unknown when its start is inside another reachable payload", () => { + const bytes = binaryWithDateInsideData(); + const parsed = parseWithoutMutatingSource(bytes); + + expect(parsed.value).toEqual([ + { $plist_type: "data", base64: "ADM/ubpeNT987g==" }, + { $plist_type: "date", iso8601: null }, + ]); + expect(parsed.limitations).toContainEqual( + expect.stringContaining("sub-millisecond source records overlap"), + ); +}); + +it("isolates an overlapping date without losing an independent date", () => { + const parsed = parsePlistBinary(binaryWithOverlappingAndIndependentDates()); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.value).toEqual([ + { $plist_type: "data", base64: "ADM/ubpeNT987g==" }, + { $plist_type: "date", iso8601: null }, + { $plist_type: "date", iso8601: "2001-01-01T00:00:00.100Z" }, + ]); + expect(parsed.value.limitations).toContainEqual( + expect.stringContaining("sub-millisecond source records overlap"), + ); +}); + +it("retains the integer sibling of a sub-millisecond overlapping date", () => { + const bytes = binaryWithOverlappingIntegerDate(); + const parsed = parseWithoutMutatingSource(bytes); + + expect(parsed.value).toEqual([{ $plist_type: "date", iso8601: null }, 42]); + expect(parsed.limitations).toContainEqual( + expect.stringContaining("sub-millisecond source records overlap"), + ); +}); + +it("isolates a shared overlapping date once while preserving both references", () => { + const parsed = parsePlistBinary(binaryWithSharedOverlappingDate()); + + if (!parsed.ok) throw parsed.error; + expect(parsed.value.value).toEqual([ + { $plist_type: "date", iso8601: null }, + { $plist_type: "date", iso8601: null }, + { $plist_type: "data", base64: "" }, + ]); + expect(parsed.value.limitations).toContainEqual( + expect.stringContaining( + "2 date value(s) are reported with unknown timestamps", + ), + ); +}); + +it("preserves valid 16-byte integers while rejecting unsupported numeric markers", () => { + const parsed = parsePlistBinary(binaryInt128()); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.value).toEqual({ + $plist_type: "integer", + decimal: "9007199254740993", + }); + + for (const [marker, message] of [ + [0x15, "integer width is invalid"], + [0x20, "real width is invalid"], + [0x24, "real width is invalid"], + ] as const) { + const rejected = parsePlistBinary(binaryInt128(marker)); + expect(rejected.ok).toBe(false); + if (rejected.ok) + throw new Error("Expected unsupported numeric marker rejection"); + expect(rejected.error.message).toContain(message); + } +}); + +it("keeps binary plist source values lost by XML conversion", () => { + const parsed = parsePlistBinary(binaryPlistWithNegativeZero()); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.value).toEqual({ + negative: { $plist_type: "real", value: "-0" }, + positive: 0, + note: "a\u0001b", + payload: { $plist_type: "data", base64: "AA==" }, + fraction: { $plist_type: "date", iso8601: "2020-05-05T01:02:03.100Z" }, + whole: { $plist_type: "date", iso8601: "2020-05-05T01:02:03.000Z" }, + nel: "a\u0085b", + ls: "a\u2028b", + ps: "a\u2029b", + largeInteger: { $plist_type: "integer", decimal: "9007199254740992" }, + }); + expect(parsed.value.limitations).toContain( + '1 negative-zero real value(s) are reported as { "$plist_type": "real", "value": "-0" } because a JSON number cannot carry the sign of zero.', + ); + expect(parsed.value.limitations).toContainEqual( + expect.stringContaining("1 observed integer literal(s)"), + ); + expect(parsed.value.limitations).toContainEqual( + expect.stringContaining("1 unambiguous integer value(s)"), + ); +}); + +it("rejects a truncated binary plist source", () => { + const parsed = parsePlistBinary(Buffer.from("bplist00bad")); + expect(parsed.ok).toBe(false); + if (parsed.ok) throw new Error("Expected malformed binary plist"); + expect(parsed.error.message).toContain("trailer is truncated"); +}); it("retains complete segmented XML keys in native plist fallback evidence", () => { const parsed = parsePlistXml( diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index 0ee5fa3d30..b6863b2e48 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -1,8 +1,13 @@ import { z } from "zod"; +import { parseBinary } from "plist"; -import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { + AnalysisOutputError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { + binaryDatePrecisionOverlapLimitation, negativeZeroReal, negativeZeroRealLimitation, nonFiniteRealLimitation, @@ -20,8 +25,22 @@ import { err, ok, type Result } from "../../domain/result.js"; import { incompletePlistNumberLimitation, + createPlistNumberProjection, type PlistNumberLiterals, } from "../../domain/apple/plistNumbers.js"; +import { + decodeBinaryPlistScalars, + scanBinaryPlistScalars, +} from "../../artifacts/apple/PlistScalars.js"; +import { + estimateBinaryPlistExpansion, + PropertyListDecodeBudgetFailure, +} from "../../artifacts/apple/PropertyListDecodeBudget.js"; +import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js"; + +/** Input ceiling matches native command output; projection is bounded separately. */ +export const MAX_NATIVE_PLIST_BYTES = 64 * 1024 * 1024; +const MAX_NATIVE_PLIST_DECODE_BYTES = 64 * 1024 * 1024; const plistObject = z.record(z.string(), z.unknown()); @@ -204,16 +223,7 @@ export const parsePlistXml = ( bundle: projectPlistBundle(value), limitations: [ 'plutil cannot express this plist as JSON, so it was decoded from plutil\'s XML conversion; data and date values are typed objects such as { "$plist_type": "data", "base64": ... }.', - ...(projected.nonFiniteRealCount === 0 - ? [] - : [nonFiniteRealLimitation(projected.nonFiniteRealCount)]), - ...negativeZeroLimitations(projected.negativeZeroRealCount), - ...(projected.unknownDateCount === 0 - ? [] - : [unknownDateLimitation(projected.unknownDateCount)]), - ...(projected.unknownScalarCount === 0 - ? [] - : [unknownScalarLimitation(projected.unknownScalarCount)]), + ...projectedScalarLimitations(projected), ...(literals.incomplete ? [incompletePlistNumberLimitation] : []), ...numberLimitations(exactIntegerCount, ambiguousNumberCount), ...prototypeKeyLimitations(omittedPrototypeKeys), @@ -221,9 +231,94 @@ export const parsePlistXml = ( }); }; +/** Decode binary plist source bytes before conversion can erase scalar facts. */ +export const parsePlistBinary = ( + bytes: Buffer, +): Result< + ParsedPlist, + AnalysisOutputError | AnalysisResourceConstraintError +> => { + try { + if (!hasBinaryPlistSignature(bytes)) + throw new TypeError("selected file does not have a binary plist header"); + const expansion = estimateBinaryPlistExpansion( + bytes, + MAX_NATIVE_PLIST_DECODE_BYTES, + { + budget: "the native plist decode budget", + numberProjection: true, + fail: (kind, message) => + kind === "cycle" + ? new TypeError( + "binary plist object references form a cycle, which a property list cannot represent", + ) + : new PropertyListDecodeBudgetFailure(kind, message), + }, + ); + const scalarMetadata = scanBinaryPlistScalars(bytes, expansion); + const decoded = parseBinary( + decodeBinaryPlistScalars(bytes, scalarMetadata, expansion), + ); + const projected = projectPlistValue(decoded); + const numbers = createPlistNumberProjection(scalarMetadata.literals); + const value = numbers.project(projected.value); + return ok({ + value, + bundle: projectPlistBundle(value), + limitations: [ + ...projectedScalarLimitations(projected), + ...(expansion.datePrecisionOverlapCount === 0 + ? [] + : [ + binaryDatePrecisionOverlapLimitation( + expansion.datePrecisionOverlapCount, + ), + ]), + ...numbers.limitations(), + ...(expansion.binaryPrototypeKeyObserved + ? [omittedPrototypeKeysLimitation(null)] + : []), + ], + }); + } catch (cause: unknown) { + if (cause instanceof PropertyListDecodeBudgetFailure) + return err( + new AnalysisResourceConstraintError( + "inspect_plist", + "memory", + cause.message, + { maximum_decoded_bytes: MAX_NATIVE_PLIST_DECODE_BYTES }, + { cause, retryable: false }, + ), + ); + return err( + new AnalysisOutputError( + "inspect_plist", + cause instanceof Error ? cause.message : String(cause), + { cause }, + ), + ); + } +}; + const prototypeKeyLimitations = (count: number): string[] => count === 0 ? [] : [omittedPrototypeKeysLimitation(count)]; +const projectedScalarLimitations = ( + projected: ProjectedPlistValue, +): string[] => [ + ...(projected.nonFiniteRealCount === 0 + ? [] + : [nonFiniteRealLimitation(projected.nonFiniteRealCount)]), + ...negativeZeroLimitations(projected.negativeZeroRealCount), + ...(projected.unknownDateCount === 0 + ? [] + : [unknownDateLimitation(projected.unknownDateCount)]), + ...(projected.unknownScalarCount === 0 + ? [] + : [unknownScalarLimitation(projected.unknownScalarCount)]), +]; + const negativeZeroLimitations = (count: number): string[] => count === 0 ? [] : [negativeZeroRealLimitation(count)]; diff --git a/tests/acceptance/applications/binaryPlistDates.test.ts b/tests/acceptance/applications/binaryPlistDates.test.ts new file mode 100644 index 0000000000..bdc78b8f4d --- /dev/null +++ b/tests/acceptance/applications/binaryPlistDates.test.ts @@ -0,0 +1,271 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { build, buildBinary, parseBinary } from "plist"; +import { expect } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { embedSubmillisecondDateInWideIntegerPayload } from "../../fixtures/binaryPlistScalarAlias.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const MUTATION_SOURCE_SECONDS = 0.2; +const SUBMILLISECOND_SECONDS = 0.1005; + +const makeArchiveBytes = (): Buffer => + Buffer.from( + buildBinary({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $top: { root: { UID: 1 } }, + $objects: [ + "$null", + { + $class: { UID: 2 }, + wide: Buffer.alloc(16), + canonicalDate: new Date("2001-01-01T00:00:00.100Z"), + preciseDate: new Date("2001-01-01T00:00:00.200Z"), + }, + { $classname: "UIView", $classes: ["UIView", "NSObject"] }, + ], + }), + ); + +const mutateDate = ( + source: Buffer, + sourceSeconds: number, + mutation: "fraction" | "marker", +): Buffer => { + const bytes = Buffer.from(source); + const date = Buffer.alloc(9); + date[0] = 0x33; + date.writeDoubleBE(sourceSeconds, 1); + const offset = bytes.indexOf(date); + expect(offset).toBeGreaterThanOrEqual(0); + expect(bytes.indexOf(date, offset + date.length)).toBe(-1); + if (mutation === "fraction") + bytes.writeDoubleBE(SUBMILLISECOND_SECONDS, offset + 1); + else bytes[offset] = 0x30; + return bytes; +}; + +const dateValues = (value: unknown): unknown[] => { + if (Array.isArray(value)) return value.flatMap(dateValues); + if (value === null || typeof value !== "object") return []; + if ("$plist_type" in value && value.$plist_type === "date") return [value]; + return Object.values(value).flatMap(dateValues); +}; + +const makeApp = async (root: string, bytes: Buffer): Promise => { + const app = join(root, "DateFixture.app"); + const contents = join(app, "Contents"); + const resources = join(contents, "Resources"); + await mkdir(resources, { recursive: true }); + await mkdir(join(contents, "MacOS")); + await writeFile( + join(contents, "MacOS", "Fixture"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await writeFile( + join(contents, "Info.plist"), + build({ CFBundleExecutable: "Fixture" }), + ); + await writeFile(join(resources, "Main.nib"), bytes); + return app; +}; + +cliTest( + "preserves binary plist date precision through keyed archive and Interface Builder CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-binary-plist-dates-"); + const sourceBytes = makeArchiveBytes(); + const parsedFixture = parseBinary(sourceBytes) as { + $top: { root: { UID: number } }; + }; + expect(parsedFixture.$top.root).toEqual({ UID: 1 }); + const bytes = mutateDate(sourceBytes, MUTATION_SOURCE_SECONDS, "fraction"); + const malformedBytes = mutateDate( + sourceBytes, + MUTATION_SOURCE_SECONDS, + "marker", + ); + const archivePath = join(root, "archive.plist"); + const malformedArchivePath = join(root, "malformed.plist"); + await Promise.all([ + writeFile(archivePath, bytes), + writeFile(malformedArchivePath, malformedBytes), + ]); + + const archiveOutput = await cli.run({ + arguments: ["inspect-keyed-archive", archivePath, "--json"], + environment: { HOME: root, USERPROFILE: root, REA_LOG_LEVEL: "silent" }, + }); + expect(archiveOutput.exitCode, archiveOutput.stderr).toBe(0); + const archiveResult = keyedArchiveResultSchema.parse( + parseEvidence(archiveOutput.json).normalized_result, + ); + expect(dateValues(archiveResult.objects)).toEqual( + expect.arrayContaining([ + { $plist_type: "date", iso8601: "2001-01-01T00:00:00.100Z" }, + { $plist_type: "date", iso8601: null }, + ]), + ); + expect(archiveResult.limitations).toContain( + '1 date value(s) are reported as { "$plist_type": "date", "iso8601": null } because the decoder could not represent their timestamps.', + ); + + const malformedOutput = await cli.run({ + arguments: ["inspect-keyed-archive", malformedArchivePath, "--json"], + environment: { HOME: root, USERPROFILE: root, REA_LOG_LEVEL: "silent" }, + }); + expect(malformedOutput.exitCode).not.toBe(0); + + const malformedApp = await makeApp(root, bytes); + const malformedNib = join(malformedApp, "Contents/Resources/Broken.nib"); + await writeFile(malformedNib, malformedBytes); + const ibOutput = await cli.run({ + arguments: ["decode-interface-builder", malformedApp, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(ibOutput.exitCode, ibOutput.stderr).toBe(0); + const ibResult = interfaceBuilderAnalysisSchema.parse( + parseEvidence(ibOutput.json).normalized_result, + ); + const view = ibResult.graph.nodes.find( + ({ attributes }) => attributes.interface_builder_object_id === "1", + ); + expect(view).toBeDefined(); + expect(dateValues(view?.attributes)).toEqual( + expect.arrayContaining([ + { $plist_type: "date", iso8601: "2001-01-01T00:00:00.100Z" }, + { $plist_type: "date", iso8601: null }, + ]), + ); + expect( + ibResult.documents.map(({ relative_path }) => relative_path), + ).toEqual(["Contents/Resources/Main.nib"]); + expect(ibResult.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "archive_decode", + status: "partial", + reason: "one_or_more_archives_invalid", + }), + ); + expect(ibResult.limitations).toContain( + 'Contents/Resources/Main.nib: 1 date value(s) are reported as { "$plist_type": "date", "iso8601": null } because the decoder could not represent their timestamps.', + ); + expect(ibResult.limitations).toContainEqual( + expect.stringContaining( + "Contents/Resources/Broken.nib: binary plist date marker 0x30 is invalid", + ), + ); + + const { call } = await connectLocalToolsMcp({ + HOME: root, + USERPROFILE: root, + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "auto", + }); + expect((await call("open_binary", { path: archivePath })).isError).not.toBe( + true, + ); + const archiveMcp = await call("inspect_keyed_archive", {}); + expect(archiveMcp.isError, JSON.stringify(archiveMcp)).not.toBe(true); + const validatedArchive = toolContract( + "inspect_keyed_archive", + ).outputSchema.parse(archiveMcp.structuredContent); + expect( + keyedArchiveResultSchema.parse( + parseEvidence(validatedArchive).normalized_result, + ), + ).toEqual(archiveResult); + + const openedBundle = await call("open_bundle_resources", { + path: malformedApp, + }); + expect(openedBundle.isError, JSON.stringify(openedBundle)).not.toBe(true); + toolContract("open_bundle_resources").outputSchema.parse( + openedBundle.structuredContent, + ); + const ibMcp = await call("decode_interface_builder", {}); + expect(ibMcp.isError, JSON.stringify(ibMcp)).not.toBe(true); + const validatedIb = toolContract( + "decode_interface_builder", + ).outputSchema.parse(ibMcp.structuredContent); + expect( + interfaceBuilderAnalysisSchema.parse( + parseEvidence(validatedIb).normalized_result, + ), + ).toEqual(ibResult); + + expect(await readFile(archivePath)).toEqual(bytes); + expect(await readFile(malformedArchivePath)).toEqual(malformedBytes); + expect( + await readFile(join(malformedApp, "Contents/Resources/Main.nib")), + ).toEqual(bytes); + expect(await readFile(malformedNib)).toEqual(malformedBytes); + expect(createHash("sha256").update(bytes).digest("hex")).toBe( + ibResult.documents[0]?.archive_sha256, + ); + }, + 60_000, +); + +cliTest( + "preserves aliased wide-integer and unknown-date values through keyed archive CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-binary-plist-scalar-alias-", + ); + const { bytes, integerDecimal } = + embedSubmillisecondDateInWideIntegerPayload(makeArchiveBytes(), 0.2); + const archivePath = join(root, "aliased-archive.plist"); + await writeFile(archivePath, bytes); + const environment = { + HOME: root, + USERPROFILE: root, + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "auto", + }; + + const cliOutput = await cli.run({ + arguments: ["inspect-keyed-archive", archivePath, "--json"], + environment, + }); + expect(cliOutput.exitCode, cliOutput.stderr).toBe(0); + const cliResult = keyedArchiveResultSchema.parse( + parseEvidence(cliOutput.json).normalized_result, + ); + const archivedValues = cliResult.objects.find(({ id }) => id === 1)?.value; + expect(archivedValues).toMatchObject({ + wide: { $plist_type: "integer", decimal: integerDecimal }, + canonicalDate: { + $plist_type: "date", + iso8601: "2001-01-01T00:00:00.100Z", + }, + preciseDate: { $plist_type: "date", iso8601: null }, + }); + + const { call } = await connectLocalToolsMcp(environment); + expect((await call("open_binary", { path: archivePath })).isError).not.toBe( + true, + ); + const response = await call("inspect_keyed_archive", {}); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const validated = toolContract("inspect_keyed_archive").outputSchema.parse( + response.structuredContent, + ); + const mcpResult = keyedArchiveResultSchema.parse( + parseEvidence(validated).normalized_result, + ); + expect(mcpResult).toEqual(cliResult); + expect(await readFile(archivePath)).toEqual(bytes); + }, + 60_000, +); diff --git a/tests/acceptance/applications/nativePlistBinaryScalars.test.ts b/tests/acceptance/applications/nativePlistBinaryScalars.test.ts new file mode 100644 index 0000000000..4a2231d082 --- /dev/null +++ b/tests/acceptance/applications/nativePlistBinaryScalars.test.ts @@ -0,0 +1,187 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { buildBinary } from "plist"; +import { describe, expect } from "vitest"; + +import { inspectPlistSchema } from "../../../src/domain/native/nativeInspection.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const patchReal = (bytes: Buffer, original: number, replacement: number) => { + const marker = Buffer.alloc(9); + marker[0] = 0x23; + marker.writeDoubleBE(original, 1); + const offset = bytes.indexOf(marker); + expect(offset).toBeGreaterThanOrEqual(0); + expect(bytes.indexOf(marker, offset + marker.length)).toBe(-1); + bytes.writeDoubleBE(replacement, offset + 1); +}; + +const makeBinaryPlist = () => { + const bytes = Buffer.from( + buildBinary({ + note: "a\u0001b", + payload: Buffer.from([0, 1, 2]), + fractional: new Date("2020-05-05T01:02:03.100Z"), + whole: new Date("2020-05-05T01:02:03.000Z"), + negative: 1.5, + positive: 0.25, + wide: 9007199254740992, + wide128: Buffer.alloc(16), + plusInfinity: Number.POSITIVE_INFINITY, + minusInfinity: Number.NEGATIVE_INFINITY, + nan: Number.NaN, + }), + ); + patchReal(bytes, 1.5, -0); + patchReal(bytes, 0.25, 0); + + const wideReal = Buffer.alloc(9); + wideReal[0] = 0x23; + wideReal.writeDoubleBE(9007199254740992, 1); + const wideOffset = bytes.indexOf(wideReal); + expect(wideOffset).toBeGreaterThanOrEqual(0); + expect(bytes.indexOf(wideReal, wideOffset + wideReal.length)).toBe(-1); + bytes[wideOffset] = 0x13; + bytes.writeBigInt64BE(9007199254740993n, wideOffset + 1); + + const trailer = bytes.length - 32; + const offsetWidth = bytes[trailer + 6] ?? 0; + const objectCount = Number(bytes.readBigUInt64BE(trailer + 8)); + const offsetTable = Number(bytes.readBigUInt64BE(trailer + 24)); + let wide128Offset: number | undefined; + for (let index = 0; index < objectCount; index += 1) { + let offset = 0; + const entry = offsetTable + index * offsetWidth; + for (let byte = 0; byte < offsetWidth; byte += 1) + offset = offset * 256 + (bytes[entry + byte] ?? 0); + if (bytes[offset] === 0x4f) { + expect(wide128Offset).toBeUndefined(); + wide128Offset = offset; + } + } + expect(wide128Offset).toBeDefined(); + if (wide128Offset === undefined) + throw new Error("plist fixture lost wide128"); + bytes[wide128Offset] = 0x14; + bytes.writeBigUInt64BE(1n, wide128Offset + 1); + bytes.writeBigUInt64BE(42n, wide128Offset + 9); + return bytes; +}; + +describe.skipIf(process.platform !== "darwin")( + "native binary plist scalar fidelity", + () => { + cliTest( + "preserves binary plist scalars through compiled CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-plist-binary-scalars-"); + const target = join(root, "target"); + const binaryPath = join(root, "selected-binary.plist"); + const xmlPath = join(root, "selected-xml.plist"); + const binaryBytes = makeBinaryPlist(); + const xmlBytes = Buffer.from( + "separatorsa\u0085b\u2028c\u2029dpayloadAAEC", + ); + await Promise.all([ + writeFile(target, thinMach(0xfeedfacf, 0x0100000c)), + writeFile(binaryPath, binaryBytes), + writeFile(xmlPath, xmlBytes), + ]); + const environment = { + HOME: root, + USERPROFILE: root, + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "auto", + }; + + const inspectCli = async (path: string) => { + const output = await cli.run({ + arguments: ["inspect-plist", path, "--json"], + environment, + }); + expect(output.exitCode, output.stderr).toBe(0); + return inspectPlistSchema.parse( + parseEvidence(output.json).normalized_result, + ); + }; + const binaryCliResult = await inspectCli(binaryPath); + const xmlCliResult = await inspectCli(xmlPath); + + const { call } = await connectLocalToolsMcp(environment); + const opened = await call("open_binary", { path: target }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const inspectMcp = async (path: string) => { + const response = await call("inspect_plist", { path }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return inspectPlistSchema.parse( + parseEvidence(response.structuredContent).normalized_result, + ); + }; + const binaryMcpResult = await inspectMcp(binaryPath); + const xmlMcpResult = await inspectMcp(xmlPath); + + expect(binaryMcpResult).toEqual(binaryCliResult); + expect(binaryCliResult).toMatchObject({ + format: "binary", + source_path: binaryPath, + value: { + note: "a\u0001b", + payload: { $plist_type: "data", base64: "AAEC" }, + fractional: { + $plist_type: "date", + iso8601: "2020-05-05T01:02:03.100Z", + }, + whole: { + $plist_type: "date", + iso8601: "2020-05-05T01:02:03.000Z", + }, + negative: { $plist_type: "real", value: "-0" }, + positive: 0, + wide: { $plist_type: "integer", decimal: "9007199254740993" }, + wide128: { + $plist_type: "integer", + decimal: "18446744073709551658", + }, + plusInfinity: { $plist_type: "real", value: "+infinity" }, + minusInfinity: { $plist_type: "real", value: "-infinity" }, + nan: { $plist_type: "real", value: "nan" }, + }, + provenance: [{ tool: "file" }], + }); + expect(binaryCliResult.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("1 negative-zero real value(s)"), + expect.stringContaining("3 non-finite real value(s)"), + expect.stringContaining("2 unambiguous integer value(s)"), + ]), + ); + expect(binaryCliResult.limitations).not.toContainEqual( + expect.stringContaining("XML conversion"), + ); + expect(xmlMcpResult).toEqual(xmlCliResult); + expect(xmlCliResult).toMatchObject({ + format: "xml", + source_path: xmlPath, + value: { + separators: "a\u0085b\u2028c\u2029d", + payload: { $plist_type: "data", base64: "AAEC" }, + }, + provenance: [ + { tool: "file" }, + { command: expect.arrayContaining(["json"]), exit: { code: 1 } }, + { command: expect.arrayContaining(["xml1"]), exit: { code: 0 } }, + ], + limitations: [expect.stringContaining("XML conversion")], + }); + expect(await readFile(binaryPath)).toEqual(binaryBytes); + expect(await readFile(xmlPath)).toEqual(xmlBytes); + }, + 60_000, + ); + }, +); diff --git a/tests/boundary/cli/nativePlistCli.test.ts b/tests/boundary/cli/nativePlistCli.test.ts index 5e0b6f3bbe..9f47e202e1 100644 --- a/tests/boundary/cli/nativePlistCli.test.ts +++ b/tests/boundary/cli/nativePlistCli.test.ts @@ -281,15 +281,27 @@ describe.skipIf(process.platform !== "darwin")( ], }, bundle: { identifier: "com.example.types", executable: "App" }, - provenance: [ - { tool: "file" }, - { command: expect.arrayContaining(["json"]), exit: { code: 1 } }, - { command: expect.arrayContaining(["xml1"]), exit: { code: 0 } }, - ], - limitations: [ - expect.stringContaining("XML conversion"), - expect.stringContaining("2 non-finite real"), - ], + provenance: + format === "xml" + ? [ + { tool: "file" }, + { + command: expect.arrayContaining(["json"]), + exit: { code: 1 }, + }, + { + command: expect.arrayContaining(["xml1"]), + exit: { code: 0 }, + }, + ] + : [{ tool: "file" }], + limitations: + format === "xml" + ? [ + expect.stringContaining("XML conversion"), + expect.stringContaining("2 non-finite real"), + ] + : [expect.stringContaining("2 non-finite real")], }, }); }, diff --git a/tests/boundary/filesystem/appleXmlDeclarations.test.ts b/tests/boundary/filesystem/appleXmlDeclarations.test.ts index 56763d8b00..df8a1676a5 100644 --- a/tests/boundary/filesystem/appleXmlDeclarations.test.ts +++ b/tests/boundary/filesystem/appleXmlDeclarations.test.ts @@ -18,7 +18,7 @@ const archive = build({ it("preflights repeated unterminated quoted declarations without suffix rescans", async () => { const preflightUrl = new URL( - "../../../dist/artifacts/apple/InterfaceBuilderDecodeBudget.js", + "../../../dist/artifacts/apple/PropertyListDecodeBudget.js", import.meta.url, ).href; const { stdout } = await promisify(execFile)( diff --git a/tests/boundary/providers/native/nativePlistBinarySource.test.ts b/tests/boundary/providers/native/nativePlistBinarySource.test.ts new file mode 100644 index 0000000000..2e40cba12f --- /dev/null +++ b/tests/boundary/providers/native/nativePlistBinarySource.test.ts @@ -0,0 +1,232 @@ +import { open, truncate, writeFile, type FileHandle } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it, onTestFinished, vi } from "vitest"; + +import { + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, +} from "../../../../src/domain/analysisErrorCore.js"; +import { ArtifactFileCleanupFailure } from "../../../../src/artifacts/ArtifactFileOperation.js"; +import { scanBinaryPlistScalars } from "../../../../src/artifacts/apple/PlistScalars.js"; +import { ProviderAdapterError } from "../../../../src/domain/providerAdapterError.js"; +import { buildBinary } from "plist"; +import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; +import { MAX_NATIVE_PLIST_BYTES } from "../../../../src/native/parsers/plist.js"; +import { + NativeFixtureRunner, + nativeMachoTarget, +} from "../../../fixtures/nativeCommands.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +class BinaryNativeRunner extends NativeFixtureRunner { + readonly tools: string[] = []; + + override async run(tool: string, arguments_: readonly string[]) { + this.tools.push(tool); + return super.run(tool, arguments_); + } +} + +const binaryPath = async () => { + const root = await createTestTempDirectory("rea-native-binary-plist-"); + const path = join(root, "selected.plist"); + await writeFile(path, Buffer.from("bplist00")); + return { root, path }; +}; + +const binaryClient = (root: string, runner: BinaryNativeRunner) => { + const client = new NativeMacOSProvider({}, runner, "darwin").createClient( + nativeMachoTarget(join(root, "target")), + ); + onTestFinished(async () => { + await client.close(); + }); + return client; +}; + +it("reports a binary source-size refusal as a typed resource limit", async () => { + const { root, path } = await binaryPath(); + await truncate(path, MAX_NATIVE_PLIST_BYTES + 1); + const runner = new BinaryNativeRunner({ + file: "Apple binary property list\n", + }); + const client = binaryClient(root, runner); + + const result = await client.execute("inspect_plist", { path }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected file-size refusal"); + expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError); + expect(result.error).toMatchObject({ + resource: "file-size", + reportedLimits: { maximum_input_bytes: MAX_NATIVE_PLIST_BYTES }, + retryable: false, + }); + expect(runner.tools).toEqual(["file"]); +}); + +it("keeps a malformed binary plist as a selected-input failure", async () => { + const { root, path } = await binaryPath(); + await writeFile(path, Buffer.from("bplist00bad")); + const runner = new BinaryNativeRunner({ + file: "Apple binary property list\n", + }); + const client = binaryClient(root, runner); + + const result = await client.execute("inspect_plist", { path }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected malformed input failure"); + expect(result.error).toBeInstanceOf(AnalysisInputError); + expect(runner.tools).toEqual(["file"]); +}); + +it("rejects a reachable binary date with an invalid marker before projection", async () => { + const { root, path } = await binaryPath(); + const bytes = Buffer.from( + buildBinary({ date: new Date("2001-01-01T00:00:00.100Z") }), + ); + const date = scanBinaryPlistScalars(bytes).dates[0]; + if (date === undefined) throw new Error("binary plist fixture lost its date"); + bytes[date.offset] = 0x30; + await writeFile(path, bytes); + const runner = new BinaryNativeRunner({ + file: "Apple binary property list\n", + }); + const client = binaryClient(root, runner); + + const result = await client.execute("inspect_plist", { path }); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected malformed date input failure"); + expect(result.error).toBeInstanceOf(AnalysisInputError); + expect(result.error).toMatchObject({ + issues: [ + { + path: ["path"], + reason: "invalid_value", + }, + ], + }); + expect(runner.tools).toEqual(["file"]); +}); + +it("preserves the exact caller cancellation reason during a binary read", async () => { + const { root, path } = await binaryPath(); + const probe = await open(path, "r"); + const prototype = Object.getPrototypeOf(probe) as FileHandle; + await probe.close(); + const originalRead = prototype.read; + const reason = { caller: "binary-plist-read" }; + const controller = new AbortController(); + const read = vi + .spyOn(prototype, "read") + .mockImplementationOnce(async function ( + this: FileHandle, + ...args: Parameters + ) { + const result = await Reflect.apply(originalRead, this, args); + controller.abort(reason); + return result; + }); + onTestFinished(() => read.mockRestore()); + const client = binaryClient( + root, + new BinaryNativeRunner({ file: "Apple binary property list\n" }), + ); + + const result = await client.execute( + "inspect_plist", + { path }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); +}); + +it("preserves a physical EIO when a pending binary read is then aborted", async () => { + const { root, path } = await binaryPath(); + const probe = await open(path, "r"); + const prototype = Object.getPrototypeOf(probe) as FileHandle; + await probe.close(); + const reason = { caller: "abort-then-eio" }; + const primary = Object.assign(new Error("controlled binary read EIO"), { + code: "EIO", + }); + const controller = new AbortController(); + const read = vi + .spyOn(prototype, "read") + .mockImplementationOnce(async function (this: FileHandle) { + controller.abort(reason); + throw primary; + }); + onTestFinished(() => read.mockRestore()); + const client = binaryClient( + root, + new BinaryNativeRunner({ file: "Apple binary property list\n" }), + ); + + const result = await client.execute( + "inspect_plist", + { path }, + { signal: controller.signal }, + ); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected physical read failure"); + expect(result.error).toBeInstanceOf(ProviderAdapterError); + expect(result.error.cause).toBe(primary); +}); + +it("retains the exact binary descriptor owner when reading and closing fail", async () => { + const { root, path } = await binaryPath(); + const probe = await open(path, "r"); + const prototype = Object.getPrototypeOf(probe) as FileHandle; + await probe.close(); + const primary = Object.assign(new Error("controlled binary read failure"), { + code: "EIO", + }); + const cleanup = new Error("controlled binary descriptor close failure"); + let attempts = 0; + let release: (() => Promise) | undefined; + const read = vi + .spyOn(prototype, "read") + .mockImplementationOnce(async function (this: FileHandle) { + release = this.close.bind(this); + this.close = async () => { + attempts += 1; + if (attempts < 3) throw cleanup; + await release!(); + }; + throw primary; + }); + onTestFinished(async () => { + read.mockRestore(); + await release?.(); + }); + const client = binaryClient( + root, + new BinaryNativeRunner({ file: "Apple binary property list\n" }), + ); + + const result = await client.execute("inspect_plist", { path }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected failed read and close"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toContain(path); + expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure); + const failure = result.error.cause as ArtifactFileCleanupFailure; + expect(failure.fileCleanupFailure.outcome).toEqual({ + kind: "failed", + cause: primary, + }); + expect(failure.fileCleanupFailure.cleanupCause).toBe(cleanup); + + expect((await client.close()).ok).toBe(false); + expect(attempts).toBe(2); + expect((await client.close()).ok).toBe(true); + expect(attempts).toBe(3); + expect(result.error.cleanupIncomplete).toBe(false); +}); diff --git a/tests/boundary/providers/native/plistSourceFormat.test.ts b/tests/boundary/providers/native/plistSourceFormat.test.ts index 0cbd27fa06..2af7d83871 100644 --- a/tests/boundary/providers/native/plistSourceFormat.test.ts +++ b/tests/boundary/providers/native/plistSourceFormat.test.ts @@ -2,6 +2,7 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; +import { buildBinary } from "plist"; import { inspectPlistSchema } from "../../../../src/domain/native/nativeInspection.js"; import { ok } from "../../../../src/domain/result.js"; @@ -49,6 +50,8 @@ interface PlistSourceCase { readonly xmlFallback: boolean; readonly convertedXml?: string; readonly expectedValue?: unknown; + readonly expectedTools?: readonly string[]; + readonly expectedLimitations?: readonly unknown[]; } it.for([ @@ -110,12 +113,32 @@ it.for([ }, { description: "Apple binary property list\n", + contents: Buffer.from( + buildBinary({ + nel: "a\u0085b", + ls: "a\u2028b", + ps: "a\u2029b", + lf: "a\nb", + payload: Buffer.from([0]), + }), + ), expected: "binary", - xmlFallback: true, + xmlFallback: false, + expectedTools: ["file"], + expectedValue: { + nel: "a\u0085b", + ls: "a\u2028b", + ps: "a\u2029b", + lf: "a\nb", + payload: { $plist_type: "data", base64: "AA==" }, + }, + expectedLimitations: [], }, { - description: "Apple binary property list\n", - expected: "binary", + description: "XML 1.0 document text, ASCII text\n", + contents: + "nela\u0085blsa\u2028bpsa\u2029blfa\nbpayloadAA==", + expected: "xml", xmlFallback: true, convertedXml: "nela\u0085blsa\u2028bpsa\u2029blfa\nbpayloadAA==", @@ -137,6 +160,8 @@ it.for([ xmlFallback, convertedXml = XML, expectedValue = { ...VALUE, ...(xmlFallback ? { Blob: DATA } : {}) }, + expectedTools, + expectedLimitations, }, { onTestFinished }, ) => { @@ -160,10 +185,12 @@ it.for([ expect(result.source_path).toBe(path); expect(result.value).toEqual(expectedValue); expect(result.provenance.map(({ tool }) => tool)).toEqual( - xmlFallback ? ["file", "plutil", "plutil"] : ["file", "plutil"], + expectedTools ?? + (xmlFallback ? ["file", "plutil", "plutil"] : ["file", "plutil"]), ); expect(result.limitations).toEqual( - xmlFallback ? [expect.stringContaining("XML conversion")] : [], + expectedLimitations ?? + (xmlFallback ? [expect.stringContaining("XML conversion")] : []), ); }, ); diff --git a/tests/fixtures/binaryPlistObjectTable.ts b/tests/fixtures/binaryPlistObjectTable.ts new file mode 100644 index 0000000000..a258f529b3 --- /dev/null +++ b/tests/fixtures/binaryPlistObjectTable.ts @@ -0,0 +1,72 @@ +export interface BinaryPlistObjectTableLayout { + readonly offsetWidth: number; + readonly objectCount: number; + readonly tableOffset: number; +} + +/** Read the trailer fields that locate a binary plist's object offset table. */ +export const readBinaryPlistObjectTableLayout = ( + bytes: Buffer, +): BinaryPlistObjectTableLayout => { + const trailer = bytes.length - 32; + return { + offsetWidth: bytes[trailer + 6] ?? 0, + objectCount: Number(bytes.readBigUInt64BE(trailer + 8)), + tableOffset: Number(bytes.readBigUInt64BE(trailer + 24)), + }; +}; + +/** Decode one object's offset-table entry using the table's declared width. */ +export const readBinaryPlistObjectOffset = ( + bytes: Buffer, + table: BinaryPlistObjectTableLayout, + objectId: number, +): number => { + const entry = table.tableOffset + objectId * table.offsetWidth; + let offset = 0; + for (let byte = 0; byte < table.offsetWidth; byte += 1) + offset = offset * 256 + (bytes[entry + byte] ?? 0); + return offset; +}; + +/** Repoint one object while preserving the table's declared offset width. */ +export const writeBinaryPlistObjectOffset = ( + bytes: Buffer, + table: BinaryPlistObjectTableLayout, + objectId: number, + offset: number, +): void => { + const entry = table.tableOffset + objectId * table.offsetWidth; + let remaining = offset; + for (let byte = table.offsetWidth - 1; byte >= 0; byte -= 1) { + bytes[entry + byte] = remaining & 0xff; + remaining = Math.floor(remaining / 256); + } + if (remaining !== 0) throw new Error("fixture offset width is insufficient"); +}; + +/** Finish a small hand-authored binary plist with one-byte object offsets. */ +export const binaryPlistObjectTable = ( + bytes: Buffer, + objectOffsets: readonly number[], + tableOffset: number, +): Buffer => { + Buffer.from("bplist00", "ascii").copy(bytes); + bytes.set(objectOffsets, tableOffset); + const trailer = bytes.length - 32; + bytes[trailer + 6] = 1; + bytes[trailer + 7] = 1; + bytes.writeBigUInt64BE(BigInt(objectOffsets.length), trailer + 8); + bytes.writeBigUInt64BE(0n, trailer + 16); + bytes.writeBigUInt64BE(BigInt(tableOffset), trailer + 24); + return bytes; +}; + +/** Date and empty-data objects deliberately share bytes in a valid offset table. */ +export const binaryPlistWithOverlappingDateAndData = (): Buffer => { + const bytes = Buffer.alloc(55); + bytes.set([0xa2, 1, 2, 0x33], 8); + bytes.writeDoubleBE(0.0005, 12); + bytes[13] = 0x40; + return binaryPlistObjectTable(bytes, [8, 11, 13], 20); +}; diff --git a/tests/fixtures/binaryPlistScalarAlias.ts b/tests/fixtures/binaryPlistScalarAlias.ts new file mode 100644 index 0000000000..9610cb592f --- /dev/null +++ b/tests/fixtures/binaryPlistScalarAlias.ts @@ -0,0 +1,47 @@ +import { scanBinaryPlistScalars } from "../../src/artifacts/apple/PlistScalars.js"; +import { + readBinaryPlistObjectOffset, + readBinaryPlistObjectTableLayout, + writeBinaryPlistObjectOffset, +} from "./binaryPlistObjectTable.js"; + +/** Place a reachable date object inside a 16-byte data object's payload. */ +export const embedSubmillisecondDateInWideIntegerPayload = ( + source: Buffer, + targetDateSeconds?: number, +): { readonly bytes: Buffer; readonly integerDecimal: string } => { + const bytes = Buffer.from(source); + const dates = scanBinaryPlistScalars(bytes).dates; + const date = + targetDateSeconds === undefined + ? dates[0] + : dates.find( + ({ secondsSince2001 }) => secondsSince2001 === targetDateSeconds, + ); + if (date === undefined) throw new Error("binary plist fixture lost its date"); + const objectTable = readBinaryPlistObjectTableLayout(bytes); + let dataOffset: number | undefined; + let dateObjectId: number | undefined; + for (let objectId = 0; objectId < objectTable.objectCount; objectId += 1) { + const offset = readBinaryPlistObjectOffset(bytes, objectTable, objectId); + if (offset === date.offset) dateObjectId = objectId; + if (bytes[offset] === 0x4f) dataOffset = offset; + } + if (dataOffset === undefined || dateObjectId === undefined) + throw new Error( + "binary plist fixture lost its wide integer or date object", + ); + + const dateOffset = dataOffset + 4; + bytes[dataOffset] = 0x14; + bytes.fill(0, dataOffset + 1, dataOffset + 17); + bytes[dateOffset] = 0x33; + bytes.writeDoubleBE(0.1005, dateOffset + 1); + writeBinaryPlistObjectOffset(bytes, objectTable, dateObjectId, dateOffset); + const integer = BigInt.asIntN( + 128, + (bytes.readBigUInt64BE(dataOffset + 1) << 64n) | + bytes.readBigUInt64BE(dataOffset + 9), + ); + return { bytes, integerDecimal: integer.toString() }; +};