import { createHash } from "node:crypto"; import { join } from "node:path"; import { parseBinary } from "plist"; import { z } from "zod"; import { AnalysisInputError, AnalysisUnsupportedTargetError, } from "../../domain/analysisErrorCore.js"; import { binaryDatePrecisionOverlapLimitation, negativeZeroRealLimitation, nonFiniteRealLimitation, unknownDateLimitation, unknownScalarLimitation, } from "../../domain/apple/plistValue.js"; import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js"; import { decodeBinaryPlistScalars, scanBinaryPlistScalars, } from "./PlistScalars.js"; import { omittedPrototypeKeysLimitation, parseXmlPropertyList, } from "../../domain/propertyListKeys.js"; import { KeyedArchiveKindError, keyedArchiveInputSchema, keyedArchiveResultSchema, projectKeyedArchive, } from "../../domain/apple/keyedArchive.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; import type { ArtifactEntry } from "../ArtifactReader.js"; import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js"; import { hasBinaryPlistSignature, hasNibArchiveSignature, } from "../../domain/apple/appleArchiveFormat.js"; import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js"; const MAX_BYTES = 64 * 1024 * 1024; /** Decoded representation budget, the same 8:1 ratio Interface Builder uses. */ const MAX_DECODE_BYTES = 8 * MAX_BYTES; /** Parse inert plist bytes, preserving data and dates with explicit typed values. */ export const decodeKeyedArchiveBytes = ( bytes: Buffer, selection: { root?: string | undefined; offset: number; limit: number }, ) => { if (bytes.length > MAX_BYTES) throw new RangeError("Keyed archive exceeds 64 MiB"); if (hasNibArchiveSignature(bytes)) throw new KeyedArchiveKindError( "Selected file is a compiled NIBArchive, not a Foundation plist archive; decode it with decode_interface_builder", "nib-archive", ); const binary = hasBinaryPlistSignature(bytes); const xmlText = binary ? undefined : decodeXmlPlistText(bytes); // plist.parseBinary recurses on every object reference and copies shared // containers, so a few hundred bytes can exhaust memory or the stack. // RangeError reports a resource limit; a reference cycle is malformed. const binaryExpansion = binary ? estimateBinaryPlistExpansion(bytes, MAX_DECODE_BYTES, { budget: "the keyed archive decode budget", fail: (kind, message) => kind === "cycle" ? new TypeError( "binary plist object references form a cycle, which a property list cannot represent", ) : new RangeError(message), bounds: "decoder", numberProjection: true, }) : undefined; const binaryScalarMetadata = binary ? scanBinaryPlistScalars(bytes, binaryExpansion) : undefined; const parsed = binaryExpansion !== undefined ? { value: parseBinary( decodeBinaryPlistScalars( bytes, binaryScalarMetadata!, binaryExpansion!, ), ), numberLiterals: binaryScalarMetadata!.literals, omittedPrototypeKeys: binaryExpansion.binaryPrototypeKeyObserved ? null : 0, } : parseXmlPropertyList(xmlText ?? ""); const { nonFiniteRealCount, negativeZeroRealCount, unknownDateCount, unknownScalarCount, ...graph } = projectKeyedArchive(parsed.value, selection); const numbers = createPlistNumberProjection(parsed.numberLiterals); // Classify the serialized graph before projecting values to typed decimals. // Numeric IDs, node kinds, references and pagination retain their original meaning. const roots = Object.fromEntries( Object.entries(graph.roots).map(([name, value]) => [ name, numbers.project(value), ]), ); const objects = graph.objects.map((object) => ({ ...object, value: numbers.project(object.value), })); return { archive_format: binary ? ("binary-plist" as const) : ("xml-plist" as const), ...graph, roots, objects, limitations: [ ...graph.limitations, ...numbers.limitations(), ...(parsed.omittedPrototypeKeys === 0 ? [] : [omittedPrototypeKeysLimitation(parsed.omittedPrototypeKeys)]), ...(nonFiniteRealCount === 0 ? [] : [nonFiniteRealLimitation(nonFiniteRealCount)]), ...(negativeZeroRealCount === 0 ? [] : [negativeZeroRealLimitation(negativeZeroRealCount)]), ...(unknownDateCount === 0 ? [] : [unknownDateLimitation(unknownDateCount)]), ...(binaryExpansion === undefined || binaryExpansion.datePrecisionOverlapCount === 0 ? [] : [ binaryDatePrecisionOverlapLimitation( binaryExpansion.datePrecisionOverlapCount, ), ]), ...(unknownScalarCount === 0 ? [] : [unknownScalarLimitation(unknownScalarCount)]), ], }; }; /** * Point to the workflow that reads the selected kind of file on this host. * decode_interface_builder is portable; inspect_plist requires macOS. */ const keyedArchiveKindRemediation = ( kind: KeyedArchiveKindError["kind"], platform: NodeJS.Platform, ): string => { if (kind === "nib-archive") return "Decode compiled NIBArchive files with decode_interface_builder on the app bundle."; return platform === "darwin" ? "Inspect an ordinary property list with inspect_plist; inspect_keyed_archive decodes NSKeyedArchiver archives only." : "Select an NSKeyedArchiver archive; inspect_keyed_archive decodes only those. inspect_plist, which reads ordinary property lists, requires a macOS host."; }; /** Read exactly one regular, contained bundle entry without following symlinks. */ export const inspectBundleKeyedArchive = async (input: { bundlePath: string; targetSha256: string | null; resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot; parameters: unknown; signal?: AbortSignal; /** Host whose available workflows the remediation names. */ platform?: NodeJS.Platform; }) => { const selected = keyedArchiveInputSchema.parse(input.parameters); if ( selected.path.startsWith("/") || selected.path .split("/") .some((part) => part === ".." || part === "." || part.length === 0) ) throw archivePathError( "invalid_format", selected.path === "." ? "Select a keyed archive path relative to the active app bundle." : `Archive path must be a canonical relative bundle path without empty, '.', or '..' segments: ${selected.path}`, ); const reader = new DirectoryArtifactReader(input.bundlePath); try { // Match inventory spelling while retaining the raw path for I/O and evidence. const wanted = selected.path.normalize("NFC"); let entry: ArtifactEntry | undefined; for await (const candidate of reader.entries(input.signal, (directory) => wanted.startsWith(`${directory.normalize("NFC")}/`), )) { if (candidate.path.normalize("NFC") !== wanted) continue; if (candidate.kind !== "file") throw archivePathError( "invalid_value", `Archive path selects a ${candidate.kind}, not a regular file: ${selected.path}`, ); if ((candidate.declaredSize ?? 0) > MAX_BYTES) throw new ArtifactReaderFailure( "limit", "Keyed archive exceeds 64 MiB", ); if (entry !== undefined) throw archivePathError( "invalid_value", `Archive path matches multiple Unicode-equivalent bundle entries; selection is ambiguous: ${selected.path}`, ); entry = candidate; } if (entry === undefined) throw archivePathError( "invalid_value", `No regular file exists at ${selected.path} in the active app bundle.`, ); const stream = await reader.open(entry, input.signal); const chunks: Buffer[] = []; let size = 0; for await (const chunk of stream) { if (input.signal?.aborted) { stream.destroy(); throw new ArtifactReaderFailure( "cancelled", "Archive inspection cancelled", ); } const bytes = z.instanceof(Buffer).parse(chunk); size += bytes.length; if (size > MAX_BYTES) { stream.destroy(); throw new ArtifactReaderFailure( "limit", "Keyed archive exceeds 64 MiB", ); } chunks.push(bytes); } const bytes = Buffer.concat(chunks, size); try { return keyedArchiveResultSchema.parse({ target_sha256: input.targetSha256, ...(input.resourceRoot === undefined ? {} : { resource_root: input.resourceRoot }), archive_path: entry.path, archive_sha256: createHash("sha256").update(bytes).digest("hex"), ...decodeKeyedArchiveBytes(bytes, selected), }); } catch (cause) { if (cause instanceof AnalysisInputError) throw cause; // An intact file of another kind is not a damaged archive. if (cause instanceof KeyedArchiveKindError) throw new AnalysisUnsupportedTargetError( "inspect_keyed_archive", join(input.bundlePath, entry.path), cause.message, { cause, remediationAction: keyedArchiveKindRemediation( cause.kind, input.platform ?? process.platform, ), }, ); throw new ArtifactReaderFailure( cause instanceof RangeError ? "limit" : "format", `Cannot decode selected Foundation keyed archive: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }, ); } } finally { await reader.close(); } }; /** A caller-selected archive path, not the artifact, failed its constraint. */ const archivePathError = ( reason: "invalid_format" | "invalid_value", message: string, ) => new AnalysisInputError("inspect_keyed_archive", undefined, [ { path: ["path"], reason, message }, ]);