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() }; +};