import { readMachoSegmentHeader } from "../../domain/apple/machoSegmentHeader.js"; import { MachoLoadCommandCursor } from "../../domain/apple/machoLoadCommands.js"; import { MAX_JVM_CLASS_IDENTITY_BYTES, JVM_CLASS_IDENTITY_LIMIT_REASON, } from "../../domain/apple/jvmClassFile.js"; import type { MachoDependency, MachoImageFacts, MachoSlice, } from "../../domain/apple/dylibResolution.js"; import { machoArchitectureName, machoCpuSubtypeBase, } from "../../domain/apple/machoCpuIdentity.js"; import { legacyMachoPlatform, type MachoVersionMinPlatform, } from "../../domain/apple/machoPlatform.js"; import { classifyFatContainerIdentity, readFatContainerHeader, readFatSliceDeclarations, readMachoHeaderPrefixFacts, validateFatSlice, validateFatSliceIdentity, type FatSliceDeclaration, } from "../../domain/apple/machoContainer.js"; /** Read `length` bytes at `offset`; shorter results mean end of file. */ export type ReadAt = (offset: number, length: number) => Promise; /** Load commands larger than this are refused instead of buffered. */ export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024; /** * Identical `LC_LAZY_LOAD_DYLIB_INFO` commands are 16 bytes, so the load-command * byte cap alone would retain about a million dependency results. Stop before * those results are kept or traced. */ export const MAX_LAZY_INFO_DEPENDENCIES = 1024; /** * Symbol names are validated and then discarded. Distinct one-byte strings * stay under the 16 MiB byte budgets while retaining one cache entry per * offset, so stop before that map and those decodes grow. */ export const MAX_LAZY_INFO_SYMBOL_REFERENCES = 64 * 1024; const MH_MAGIC = 0xfeedface; const MH_MAGIC_64 = 0xfeedfacf; const MH_CIGAM = 0xcefaedfe; const MH_CIGAM_64 = 0xcffaedfe; const FAT_MAGIC = 0xcafebabe; const FAT_MAGIC_64 = 0xcafebabf; const FAT_CIGAM = 0xbebafeca; const FAT_CIGAM_64 = 0xbfbafeca; const LC_SEGMENT = 0x1; const LC_SEGMENT_64 = 0x19; const LC_ID_DYLIB = 0xd; const LC_LOAD_DYLIB = 0xc; const LC_LOAD_WEAK_DYLIB = 0x80000018; const LC_REEXPORT_DYLIB = 0x8000001f; const LC_LAZY_LOAD_DYLIB = 0x20; const LC_LAZY_LOAD_DYLIB_INFO = 0x3a; const LC_LOAD_UPWARD_DYLIB = 0x80000023; const LC_RPATH = 0x8000001c; const LC_DYLD_ENVIRONMENT = 0x27; const LC_CODE_SIGNATURE = 0x1d; const LC_BUILD_VERSION = 0x32; const CPU_TYPE_ARM64 = 0x0100000c; /** loader.h command codes and their device platform identities. */ const VERSION_MIN_PLATFORMS: ReadonlyMap = new Map([ [0x24, 1], // LC_VERSION_MIN_MACOSX [0x25, 2], // LC_VERSION_MIN_IPHONEOS [0x2f, 3], // LC_VERSION_MIN_TVOS [0x30, 4], // LC_VERSION_MIN_WATCHOS ]); /** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */ const DYLIB_USE_MARKER = 0x1a741800; const LAZY_INFO_HEADER_BYTES = 24; /** ld64 may-be-missing bit: the lazy image is allowed to be absent. */ const LAZY_INFO_MAY_BE_MISSING = 0x1; /** * dyld's linkedit image-offset checks start at one page, then grow to the * runtime end of every segment except `__LINKEDIT`. */ const LAZY_INFO_MIN_VM_OFFSET = 0x4000n; const DYLIB_USE_WEAK_LINK = 0x1; const DYLIB_USE_REEXPORT = 0x2; const DYLIB_USE_UPWARD = 0x4; const DYLIB_USE_DELAYED_INIT = 0x8; const DEPENDENCY_COMMANDS: ReadonlyMap = new Map([ [LC_LOAD_DYLIB, "LC_LOAD_DYLIB"], [LC_LOAD_WEAK_DYLIB, "LC_LOAD_WEAK_DYLIB"], [LC_REEXPORT_DYLIB, "LC_REEXPORT_DYLIB"], [LC_LAZY_LOAD_DYLIB, "LC_LAZY_LOAD_DYLIB"], [LC_LOAD_UPWARD_DYLIB, "LC_LOAD_UPWARD_DYLIB"], ]); /** Malformed or unsupported structure found while decoding one image. */ class MachoFormatIssue extends Error { constructor( readonly status: "malformed" | "unsupported", message: string, ) { super(message); } } /** Report whether the first bytes carry a Mach-O or FAT magic number. */ export const hasMachoMagic = (head: Uint8Array): boolean => { if (head.byteLength < 4) return false; const magic = new DataView( head.buffer, head.byteOffset, head.byteLength, ).getUint32(0, false); return [ MH_MAGIC, MH_MAGIC_64, MH_CIGAM, MH_CIGAM_64, FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64, ].includes(magic); }; /** Decode every slice's dylib-loading load commands without reading code. */ export const readMachoImage = async ( readAt: ReadAt, size: number, ): Promise => { try { const head = await readAt(0, Math.min(size, 8)); if (head.byteLength < 8) // A recognized magic with no room for a header is a truncated image. return hasMachoMagic(head) ? { status: "malformed", reason: "Mach-O header is truncated" } : { status: "not-mach-o" }; const view = viewOf(head); const magic = view.getUint32(0, false); let header: ReturnType; if (magic === FAT_MAGIC && size <= MAX_JVM_CLASS_IDENTITY_BYTES) { const complete = await readAt(0, size); header = complete.byteLength === size ? readFatContainerHeader(complete, size) : readFatContainerHeader(head, size); } else header = readFatContainerHeader(head, size); if (header.state !== "not-fat") { if (header.state === "needs-data") { header = readFatContainerHeader( await readAt(0, header.requiredBytes), size, ); } if (header.state === "not-fat") return { status: "not-mach-o" }; if (header.state === "needs-data") throw new MachoFormatIssue("malformed", "FAT table is truncated"); if (header.state === "malformed") throw new MachoFormatIssue("malformed", header.reason); let identity = classifyFatContainerIdentity(header, () => undefined); if (identity.state === "needs-slice-header") { const sliceHeaders = new Map(); for (const offset of identity.offsets) { const bytes = await readAt(offset, 4); sliceHeaders.set( offset, bytes.byteLength < 4 ? null : viewOf(bytes).getUint32(0, false), ); } identity = classifyFatContainerIdentity(header, (offset) => sliceHeaders.get(offset), ); } if (identity.state === "not-mach-o") return { status: "not-mach-o" }; if (identity.state === "malformed") { // A large CAFEBABE input whose bounded prefix resembles an invalid // known-CPU FAT table remains unknown until its JVM identity can be // checked; do not turn the resource limit into a malformed claim. if (magic === FAT_MAGIC && size > MAX_JVM_CLASS_IDENTITY_BYTES) throw new MachoFormatIssue( "unsupported", JVM_CLASS_IDENTITY_LIMIT_REASON, ); throw new MachoFormatIssue(identity.category, identity.reason); } if (identity.state === "needs-slice-header") return { status: "not-mach-o" }; const hiddenSlice = await readAppleHiddenArm64Slice(readAt, size, header); return { status: "parsed", slices: await readFatSlices(readAt, header.slices), ...(hiddenSlice === null ? {} : { hiddenSlices: [hiddenSlice] }), }; } if (magic === MH_CIGAM || magic === MH_CIGAM_64) return { status: "parsed", slices: [await readSlice(readAt, 0, size)] }; if (magic === MH_MAGIC || magic === MH_MAGIC_64) throw new MachoFormatIssue( "unsupported", "big-endian Mach-O images are not supported", ); return { status: "not-mach-o" }; } catch (cause: unknown) { if (cause instanceof MachoFormatIssue) return { status: cause.status, reason: cause.message }; throw cause; } }; /** * Apple lipo stores one hidden ARM64 row after the declared 32-bit FAT table. * It remains separate from the raw nfat_arch declarations and is only admitted * from the single table-to-first-slice slot cctools recognizes. */ const readAppleHiddenArm64Slice = async ( readAt: ReadAt, fileSize: number, header: Extract< ReturnType, { state: "parsed" } >, ): Promise => { if (header.wide) return null; const firstOffset = Math.min(...header.slices.map(({ offset }) => offset)); const end = header.tableEnd + 20; if (end > fileSize || end > firstOffset) return null; const bytes = await readAt(header.tableEnd, 20); if (bytes.byteLength < 20) throw new MachoFormatIssue( "malformed", "Hidden ARM64 FAT row is truncated", ); const row = readFatSliceDeclarations(bytes, { count: 1, wide: false, littleEndian: header.littleEndian, }); if (row.status !== "parsed") throw new MachoFormatIssue("malformed", row.reason); const declaration = { ...row.slices[0]!, index: header.count }; if (declaration.cpuType !== CPU_TYPE_ARM64) return null; const rangeIssue = validateFatSlice(declaration, fileSize, end); if (rangeIssue !== null) throw new MachoFormatIssue("malformed", rangeIssue); const slice = await readSlice(readAt, declaration.offset, declaration.size, { fatCpuType: declaration.cpuType, fatCpuSubtype: declaration.cpuSubtype, alignmentExponent: declaration.alignmentExponent, }); const identityIssue = validateFatSliceIdentity(declaration, { cpuType: slice.cpu_type, cpuSubtype: slice.cpu_subtype, }); if (identityIssue !== null) throw new MachoFormatIssue("malformed", identityIssue); return { ...slice, hidden: true }; }; const readFatSlices = async ( readAt: ReadAt, declarations: readonly FatSliceDeclaration[], ): Promise => { const slices: MachoSlice[] = []; // Keep validating after an unsupported slice so a later malformed slice is // reported as malformed whatever the slice order. let unsupported: MachoFormatIssue | undefined; for (const declaration of declarations) { const { offset, size: length, cpuType, cpuSubtype } = declaration; let slice: MachoSlice; try { slice = await readSlice(readAt, offset, length, { fatCpuType: cpuType, fatCpuSubtype: cpuSubtype, alignmentExponent: declaration.alignmentExponent, }); } catch (cause: unknown) { if ( !(cause instanceof MachoFormatIssue) || cause.status !== "unsupported" ) throw cause; unsupported ??= cause; continue; } const identityIssue = validateFatSliceIdentity(declaration, { cpuType: slice.cpu_type, cpuSubtype: slice.cpu_subtype, }); if (identityIssue !== null) throw new MachoFormatIssue("malformed", identityIssue); slices.push(slice); } if (unsupported !== undefined) throw unsupported; return slices; }; const readSlice = async ( readAt: ReadAt, base: number, length: number, fat: { readonly fatCpuType: number; readonly fatCpuSubtype: number; readonly alignmentExponent: number; } | null = null, ): Promise => { const header = viewOf(await readExact(readAt, base, 28, "Mach-O header")); const headerBytes = new Uint8Array( header.buffer, header.byteOffset, header.byteLength, ); const identity = readMachoHeaderPrefixFacts(headerBytes).identity; const magic = header.getUint32(0, true); if (magic === MH_MAGIC_64 || magic === MH_MAGIC) { if (identity === null) throw new MachoFormatIssue( "malformed", `slice at offset ${base} has no Mach-O header`, ); const wide = magic === MH_MAGIC_64; const headerSize = wide ? 32 : 28; const commandCount = header.getUint32(16, true); const commandBytes = header.getUint32(20, true); if (commandBytes > MAX_LOAD_COMMAND_BYTES) throw new MachoFormatIssue( "unsupported", `load commands occupy ${commandBytes} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`, ); if (headerSize + commandBytes > length) throw new MachoFormatIssue( "malformed", "load commands extend beyond the Mach-O slice", ); const commands = await readExact( readAt, base + headerSize, commandBytes, "load commands", ); const decoded = decodeCommands(commands, { wide, cpuType: identity.cpuType, cpuSubtype: identity.cpuSubtype, fileType: header.getUint32(12, true), commandCount, sliceOffset: base, sliceSize: length, fatCpuType: fat?.fatCpuType ?? null, fatCpuSubtype: fat?.fatCpuSubtype ?? null, fatAlignmentExponent: fat?.alignmentExponent ?? null, }); const slice = decoded.slice; await appendDependencies( slice, decoded.dependencies, readAt, base, length, decoded.maxVmOffset, ); const distinctPlatforms = [...new Set(slice.platforms)]; slice.platform = distinctPlatforms.length === 1 ? (distinctPlatforms[0] ?? null) : null; return slice; } throw new MachoFormatIssue( magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64) ? "unsupported" : "malformed", magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64) ? "big-endian Mach-O slices are not supported" : `slice at offset ${base} has no Mach-O header`, ); }; const decodeCommands = ( bytes: Uint8Array, header: { readonly wide: boolean; readonly cpuType: number; readonly cpuSubtype: number; readonly fileType: number; readonly commandCount: number; readonly sliceOffset: number; readonly sliceSize: number; readonly fatCpuType: number | null; readonly fatCpuSubtype: number | null; readonly fatAlignmentExponent: number | null; }, ): { readonly slice: MachoSlice; readonly dependencies: readonly ( | { readonly kind: "parsed"; readonly value: MachoDependency } | LazyLoadDylibInfoCommand )[]; readonly maxVmOffset: bigint; } => { const slice: MachoSlice = { architecture: machoArchitectureName(header.cpuType, header.cpuSubtype), slice_offset: header.sliceOffset, slice_size: header.sliceSize, cpu_type: header.cpuType, cpu_subtype: header.cpuSubtype, fat_cpu_type: header.fatCpuType, fat_cpu_subtype: header.fatCpuSubtype, fat_alignment_exponent: header.fatAlignmentExponent, file_type: fileTypeName(header.fileType), file_type_code: header.fileType, platform: null, platforms: [], install_name: null, dependencies: [], rpaths: [], dyld_environment: [], code_signature_present: false, }; const dependencies: Array< | { readonly kind: "parsed"; readonly value: MachoDependency } | LazyLoadDylibInfoCommand > = []; const segments: SegmentSpan[] = []; let unreadableSegment: number | undefined; let lazyInfo = false; let lazyInfoCount = 0; const commands = new MachoLoadCommandCursor({ wide: header.wide, littleEndian: true, commandCount: header.commandCount, commandBytes: bytes.byteLength, }); while (commands.nextOffset !== null) { const offset = commands.nextOffset; const frame = commands.read(bytes.subarray(offset, offset + 8)); if (!frame.ok) throw new MachoFormatIssue("malformed", frame.error); const { index, command, size } = frame.value; const body = bytes.subarray(offset, offset + size); if (command === LC_SEGMENT || command === LC_SEGMENT_64) { const span = readSegmentSpan(body, command === LC_SEGMENT_64); if (span === undefined) unreadableSegment ??= index; else segments.push(span); } if (command === LC_LAZY_LOAD_DYLIB_INFO) { lazyInfoCount += 1; if (lazyInfoCount > MAX_LAZY_INFO_DEPENDENCIES) throw new MachoFormatIssue( "unsupported", `LC_LAZY_LOAD_DYLIB_INFO commands exceed the ${MAX_LAZY_INFO_DEPENDENCIES}-dependency limit`, ); lazyInfo = true; } const decoded = decodeCommand(command, body, index, slice); if (decoded !== undefined) dependencies.push(decoded); } const complete = commands.finish(); if (!complete.ok) throw new MachoFormatIssue("malformed", complete.error); if (lazyInfo && unreadableSegment !== undefined) throw new MachoFormatIssue( "malformed", `load command ${unreadableSegment} is too short to establish the slice VM range for LC_LAZY_LOAD_DYLIB_INFO`, ); return { slice, dependencies, maxVmOffset: maximumVmOffset(segments) }; }; interface LazyLoadDylibInfoCommand { readonly kind: "lazy-info"; readonly index: number; readonly dataOffset: number; readonly dataSize: number; } interface SegmentSpan { readonly name: string; readonly vmaddr: bigint; readonly vmsize: bigint; } /** Runtime end of the mapped image, excluding `__LINKEDIT`, floored at one page. */ const maximumVmOffset = (segments: readonly SegmentSpan[]): bigint => { let textVmAddr = 0n; for (const segment of segments) { if (segment.name === "__TEXT") { textVmAddr = segment.vmaddr; break; } } let max = LAZY_INFO_MIN_VM_OFFSET; for (const segment of segments) { if (segment.name === "__LINKEDIT") continue; const end = segment.vmaddr - textVmAddr + segment.vmsize; if (end > max) max = end; } return max; }; const readSegmentSpan = ( body: Uint8Array, wide: boolean, ): SegmentSpan | undefined => { const parsed = readMachoSegmentHeader(body, wide, true); if (!parsed.ok) return undefined; const { nameBytes, address: vmaddr, memorySize: vmsize } = parsed.value; const end = nameBytes.indexOf(0); return { name: new TextDecoder("utf-8", { fatal: false }).decode( end < 0 ? nameBytes : nameBytes.subarray(0, end), ), vmaddr, vmsize, }; }; interface CachedLazyString { readonly text: string; /** Absolute file offset of the terminating NUL. */ readonly end: number; } interface LazyPayloadWork { readonly dependencies: Map; readonly reads: { fileOffset: number; bytes: Uint8Array }[]; readonly strings: Map; payloadBytes: number; /** Scanned string bytes. Repeats and overlaps each count in full. */ stringBytes: number; /** Symbol-string references. Repeats each count. */ symbolReferences: number; rangeChecks: number; } const containedPayload = ( reads: readonly { fileOffset: number; bytes: Uint8Array }[], fileOffset: number, size: number, work: LazyPayloadWork, ): Uint8Array | undefined => { for (const read of reads) { work.rangeChecks += 1; if (work.rangeChecks > MAX_LOAD_COMMAND_BYTES) throw new MachoFormatIssue( "unsupported", `LC_LAZY_LOAD_DYLIB_INFO payload range lookup exceeds the ${MAX_LOAD_COMMAND_BYTES}-check limit`, ); const start = fileOffset - read.fileOffset; if ( start >= 0 && Number.isSafeInteger(start) && start + size <= read.bytes.byteLength ) return read.bytes.subarray(start, start + size); } return undefined; }; const appendDependencies = async ( slice: MachoSlice, dependencies: readonly ( | { readonly kind: "parsed"; readonly value: MachoDependency } | LazyLoadDylibInfoCommand )[], readAt: ReadAt, base: number, length: number, maxVmOffset: bigint, ): Promise => { const work: LazyPayloadWork = { dependencies: new Map(), reads: [], strings: new Map(), payloadBytes: 0, stringBytes: 0, symbolReferences: 0, rangeChecks: 0, }; for (const dependency of dependencies) { if (dependency.kind === "parsed") { slice.dependencies.push(dependency.value); continue; } const key = `${dependency.dataOffset}:${dependency.dataSize}`; const cached = work.dependencies.get(key); if (cached !== undefined) { slice.dependencies.push(cached); continue; } const value = await readLazyLoadDylibInfo( readAt, base, length, dependency, maxVmOffset, work, ); work.dependencies.set(key, value); slice.dependencies.push(value); } }; const decodeCommand = ( command: number, body: Uint8Array, index: number, slice: MachoSlice, ): | { readonly kind: "parsed"; readonly value: MachoDependency } | LazyLoadDylibInfoCommand | undefined => { if (command === LC_ID_DYLIB) { slice.install_name = commandString(body, 24, index); return; } if (command === LC_RPATH) { slice.rpaths.push(commandString(body, 12, index)); return; } if (command === LC_DYLD_ENVIRONMENT) { slice.dyld_environment.push(commandString(body, 12, index)); return; } if (command === LC_CODE_SIGNATURE) { // linkedit_data_command: cmd, cmdsize, dataoff, datasize. if (body.byteLength < 16) throw new MachoFormatIssue( "malformed", `load command ${index} is too short for LC_CODE_SIGNATURE`, ); slice.code_signature_present = true; return; } if (command === LC_BUILD_VERSION) { if (body.byteLength < 24) throw new MachoFormatIssue( "malformed", `load command ${index} is too short for LC_BUILD_VERSION`, ); const view = viewOf(body); const toolCount = view.getUint32(20, true); if (toolCount > Math.floor((body.byteLength - 24) / 8)) throw new MachoFormatIssue( "malformed", `load command ${index} LC_BUILD_VERSION tool records exceed cmdsize`, ); const platform = view.getUint32(8, true); slice.platforms.push(platform); return; } const legacyPlatform = VERSION_MIN_PLATFORMS.get(command); if (legacyPlatform !== undefined) { if (body.byteLength < 16) throw new MachoFormatIssue( "malformed", `load command ${index} is too short for a version-min command`, ); slice.platforms.push(legacyMachoPlatform(legacyPlatform, slice.cpu_type)); return; } if (command === LC_LAZY_LOAD_DYLIB_INFO) return decodeLazyLoadDylibInfoCommand(body, index); const name = DEPENDENCY_COMMANDS.get(command); if (name !== undefined) return { kind: "parsed", value: decodeDependency(name, body, index) }; return undefined; }; /** * `LC_LAZY_LOAD_DYLIB_INFO` is a linkedit-data command. The install name is * not in the command; it is a NUL-terminated string in the payload. */ const decodeLazyLoadDylibInfoCommand = ( body: Uint8Array, index: number, ): LazyLoadDylibInfoCommand => { if (body.byteLength < 16) throw new MachoFormatIssue( "malformed", `load command ${index} is too short for LC_LAZY_LOAD_DYLIB_INFO`, ); const view = viewOf(body); const dataOffset = view.getUint32(8, true); const dataSize = view.getUint32(12, true); if (dataSize < LAZY_INFO_HEADER_BYTES) throw new MachoFormatIssue( "malformed", `load command ${index} LC_LAZY_LOAD_DYLIB_INFO payload is shorter than its 24-byte header`, ); if (dataSize > MAX_LOAD_COMMAND_BYTES) throw new MachoFormatIssue( "unsupported", `LC_LAZY_LOAD_DYLIB_INFO payload occupies ${dataSize} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`, ); return { kind: "lazy-info", index, dataOffset, dataSize }; }; const readLazyPayloadBytes = async ( readAt: ReadAt, fileOffset: number, size: number, index: number, work: LazyPayloadWork, ): Promise => { const covered = containedPayload(work.reads, fileOffset, size, work); if (covered !== undefined) return covered; work.payloadBytes += size; if (work.payloadBytes > MAX_LOAD_COMMAND_BYTES) throw new MachoFormatIssue( "unsupported", `LC_LAZY_LOAD_DYLIB_INFO payloads occupy ${work.payloadBytes} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte aggregate limit`, ); const payload = await readExact( readAt, fileOffset, size, `load command ${index} LC_LAZY_LOAD_DYLIB_INFO payload`, ); work.reads.push({ fileOffset, bytes: payload }); return payload; }; const readLazyLoadDylibInfo = async ( readAt: ReadAt, base: number, length: number, command: LazyLoadDylibInfoCommand, maxVmOffset: bigint, work: LazyPayloadWork, ): Promise => { if ( command.dataOffset > length || command.dataSize > length - command.dataOffset ) throw new MachoFormatIssue( "malformed", `load command ${command.index} LC_LAZY_LOAD_DYLIB_INFO payload extends beyond the Mach-O slice`, ); const fileOffset = base + command.dataOffset; if (!Number.isSafeInteger(fileOffset)) throw new MachoFormatIssue( "malformed", `load command ${command.index} LC_LAZY_LOAD_DYLIB_INFO payload offset is not a safe integer`, ); const payload = await readLazyPayloadBytes( readAt, fileOffset, command.dataSize, command.index, work, ); return decodeLazyLoadDylibInfoPayload( payload, command.index, fileOffset, maxVmOffset, work, ); }; const decodeLazyLoadDylibInfoPayload = ( payload: Uint8Array, index: number, fileOffset: number, maxVmOffset: bigint, work: LazyPayloadWork, ): MachoDependency => { const view = viewOf(payload); const pathOffset = view.getUint32(0, true); const flagImageOffset = view.getUint32(4, true); const flags = view.getUint16(8, true); const chainStartImageOffset = view.getUint32(12, true); const symbolCount = view.getUint32(16, true); const symbolArrayOffset = view.getUint32(20, true); if (BigInt(flagImageOffset) > maxVmOffset) throw new MachoFormatIssue( "malformed", `load command ${index} LC_LAZY_LOAD_DYLIB_INFO flag image offset 0x${flagImageOffset.toString(16)} is beyond the slice VM range 0x${maxVmOffset.toString(16)}`, ); if (BigInt(chainStartImageOffset) > maxVmOffset) throw new MachoFormatIssue( "malformed", `load command ${index} LC_LAZY_LOAD_DYLIB_INFO chain start image offset 0x${chainStartImageOffset.toString(16)} is beyond the slice VM range 0x${maxVmOffset.toString(16)}`, ); if ((flags & ~LAZY_INFO_MAY_BE_MISSING) !== 0) throw new MachoFormatIssue( "unsupported", `load command ${index} LC_LAZY_LOAD_DYLIB_INFO flags 0x${flags.toString(16)} include unrecognized bits`, ); if ( symbolArrayOffset > payload.byteLength || symbolCount > (payload.byteLength - symbolArrayOffset) / 4 ) throw new MachoFormatIssue( "malformed", `load command ${index} LC_LAZY_LOAD_DYLIB_INFO symbol table extends beyond the payload`, ); const installName = payloadString( payload, pathOffset, fileOffset, index, "load path", work, true, ); const symbolReferences = work.symbolReferences + symbolCount; if (symbolReferences > MAX_LAZY_INFO_SYMBOL_REFERENCES) throw new MachoFormatIssue( "unsupported", `LC_LAZY_LOAD_DYLIB_INFO symbol validation exceeds the ${MAX_LAZY_INFO_SYMBOL_REFERENCES}-reference limit`, ); work.symbolReferences = symbolReferences; for (let symbol = 0; symbol < symbolCount; symbol += 1) { const offset = view.getUint32(symbolArrayOffset + symbol * 4, true); payloadString( payload, offset, fileOffset, index, `symbol ${symbol}`, work, false, ); } return { command: "LC_LAZY_LOAD_DYLIB_INFO", encoding: "lazy_load_dylib_info", install_name: installName, weak: (flags & LAZY_INFO_MAY_BE_MISSING) !== 0, upward: false, reexport: false, delayed_init: false, current_version: null, compatibility_version: null, }; }; const chargeLazyString = (work: LazyPayloadWork, examined: number): void => { const occupied = work.stringBytes + examined; if (occupied > MAX_LOAD_COMMAND_BYTES) throw new MachoFormatIssue( "unsupported", `LC_LAZY_LOAD_DYLIB_INFO string validation occupies ${occupied} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`, ); work.stringBytes = occupied; }; /** * NUL-terminated UTF-8 string after the fixed payload header. * dyld's load-path scan stops before the payload's final byte, so a NUL * in that byte does not terminate the path. */ const payloadString = ( payload: Uint8Array, offset: number, fileOffset: number, index: number, label: string, work: LazyPayloadWork, excludeFinalByte: boolean, ): string => { if (offset < LAZY_INFO_HEADER_BYTES) throw new MachoFormatIssue( "malformed", `load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} offset ${offset} is inside the payload header`, ); if (offset >= payload.byteLength) throw new MachoFormatIssue( "malformed", `load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} offset ${offset} is outside the payload`, ); const absolute = Number.isSafeInteger(fileOffset + offset) ? fileOffset + offset : undefined; const cached = absolute === undefined ? undefined : work.strings.get(absolute); const terminatorLimit = excludeFinalByte ? payload.byteLength - 1 : payload.byteLength; if (cached !== undefined && absolute !== undefined) { const end = cached.end - fileOffset; if (end < offset || end >= terminatorLimit) throw new MachoFormatIssue( "malformed", `load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is unterminated`, ); chargeLazyString(work, cached.end - absolute + 1); return cached.text; } if (offset >= terminatorLimit) throw new MachoFormatIssue( "malformed", `load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is unterminated`, ); const remaining = MAX_LOAD_COMMAND_BYTES - work.stringBytes; const windowEnd = Math.min(terminatorLimit, offset + remaining); const end = payload.subarray(0, windowEnd).indexOf(0, offset); if (end < 0) { if (windowEnd < terminatorLimit) throw new MachoFormatIssue( "unsupported", `LC_LAZY_LOAD_DYLIB_INFO string validation occupies ${work.stringBytes + (windowEnd - offset) + 1} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`, ); throw new MachoFormatIssue( "malformed", `load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is unterminated`, ); } chargeLazyString(work, end - offset + 1); let text: string; try { text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( payload.subarray(offset, end), ); } catch { throw new MachoFormatIssue( "malformed", `load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is not UTF-8`, ); } if (absolute !== undefined && Number.isSafeInteger(fileOffset + end)) work.strings.set(absolute, { text, end: fileOffset + end }); return text; }; const decodeDependency = ( command: MachoDependency["command"], body: Uint8Array, index: number, ): MachoDependency => { if (body.byteLength < 24) throw new MachoFormatIssue( "malformed", `load command ${index} is too short for a dylib command`, ); const view = viewOf(body); const nameOffset = view.getUint32(8, true); const usesFlags = nameOffset === 28 && body.byteLength >= 28 && view.getUint32(12, true) === DYLIB_USE_MARKER; const flags = usesFlags ? view.getUint32(24, true) : 0; return { command, encoding: usesFlags ? "dylib_use_command" : "dylib_command", install_name: commandString(body, usesFlags ? 28 : 24, index), weak: command === "LC_LOAD_WEAK_DYLIB" || (flags & DYLIB_USE_WEAK_LINK) !== 0, upward: command === "LC_LOAD_UPWARD_DYLIB" || (flags & DYLIB_USE_UPWARD) !== 0, reexport: command === "LC_REEXPORT_DYLIB" || (flags & DYLIB_USE_REEXPORT) !== 0, delayed_init: (flags & DYLIB_USE_DELAYED_INIT) !== 0, current_version: version(view.getUint32(16, true)), compatibility_version: version(view.getUint32(20, true)), }; }; /** NUL-terminated UTF-8 string at a `lc_str` offset inside one command. */ const commandString = ( body: Uint8Array, minimumOffset: number, index: number, ): string => { if (body.byteLength < 12) throw new MachoFormatIssue( "malformed", `load command ${index} is too short to hold a string offset`, ); const offset = viewOf(body).getUint32(8, true); if (offset < minimumOffset || offset >= body.byteLength) throw new MachoFormatIssue( "malformed", `load command ${index} has string offset ${offset} outside the command`, ); const end = body.indexOf(0, offset); if (end < 0) throw new MachoFormatIssue( "malformed", `load command ${index} has an unterminated string`, ); try { // lc_str contains name/path data, so a leading U+FEFF is part of its identity. return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( body.subarray(offset, end), ); } catch { throw new MachoFormatIssue( "malformed", `load command ${index} has a string that is not UTF-8`, ); } }; const readExact = async ( readAt: ReadAt, offset: number, length: number, label: string, ): Promise => { const bytes = await readAt(offset, length); if (bytes.byteLength < length) throw new MachoFormatIssue("malformed", `${label} is truncated`); return bytes; }; const fileTypeName = (fileType: number): MachoSlice["file_type"] => fileType === 2 ? "execute" : fileType === 6 ? "dylib" : fileType === 8 ? "bundle" : "other"; const version = (value: number): string => `${value >>> 16}.${(value >>> 8) & 0xff}.${value & 0xff}`; const swap = (value: number): number => (((value & 0xff) << 24) | ((value & 0xff00) << 8) | ((value >>> 8) & 0xff00) | (value >>> 24)) >>> 0; const viewOf = (bytes: Uint8Array): DataView => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);