From 5796220f23d60381f96c00114a89cc70bb90cdf6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:15:08 +0800 Subject: [PATCH] fix(native): validate Mach-O command framing before admission --- docs/native-investigation.md | 7 + src/application/BinaryTargetResolver.ts | 115 ++++++++++++- src/artifacts/apple/MachoLoadCommandReader.ts | 40 ++--- src/domain/apple/machoLoadCommands.ts | 104 ++++++++++++ src/domain/binaryTarget.ts | 47 +++--- src/native/AppleMachoSelection.test.ts | 2 +- src/native/AppleMachoSelection.ts | 28 ++-- src/native/metadata/MachoImage.ts | 40 ++--- .../filesystem/machoCommandAdmission.test.ts | 152 ++++++++++++++++++ 9 files changed, 446 insertions(+), 89 deletions(-) create mode 100644 src/domain/apple/machoLoadCommands.ts create mode 100644 tests/boundary/filesystem/machoCommandAdmission.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 79040fca27..80f1f8831f 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -358,6 +358,13 @@ Mach-O fixtures check each case against source-owned return values. ## Static inspection +Thin Mach-O admission and each admitted FAT slice walk declared load-command headers, checking +width-specific alignment, command sizes and exact `ncmds`/`sizeofcmds` agreement +before opening a provider. Headers beyond the initial probe use another +4096-byte window; admission skips command payloads. Native image readers use +the same framing owner and perform their own payload checks. This validation +does not add a global command-table byte cap. + Native target admission checks Mach-O slice headers before assigning FAT identity. A shared `CAFEBABE` prefix whose declarations do not identify Mach-O images receives the generic unsupported-format refusal, including JVM class diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 3cd929f071..4c08e862d1 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -79,7 +79,15 @@ import { type FatSliceHeaderEvidence, type MachoHeaderPrefixFacts, } from "../domain/apple/machoContainer.js"; -import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js"; +import { + MachoLoadCommandCursor, + readMachoLoadCommandTableHeader, + type MachoLoadCommandTableHeader, +} from "../domain/apple/machoLoadCommands.js"; +import type { + PeCodeViewRecord, + UniversalMachOSlice, +} from "../domain/binaryTargetTypes.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -844,6 +852,12 @@ const readExecutableMetadata = async ( signatureEvidence, ); } + const thin = await readThinMachoMetadata(handle, bytes, { + fileSize, + hostArchitecture, + signal, + }); + if (thin !== null) return thin; const fatEvidence = await readFatSliceHeaderEvidence( handle, bytes, @@ -857,6 +871,16 @@ const readExecutableMetadata = async ( undefined, fatEvidence, ); + if ( + parsed.ok && + parsed.value.format === "mach-o-universal" && + fatEvidence !== undefined + ) { + for (const slice of fatEvidence.header.slices) { + const framed = await readMachoSliceCommandFrames(handle, slice, signal); + if (!framed.ok) return framed; + } + } if ( !parsed.ok || parsed.value.architecture !== "mips" || @@ -921,6 +945,95 @@ const unresolvedMzHeaderFailure = ( }; }; +/** Establish a complete thin-command framing proof before provider admission. */ +const readThinMachoMetadata = async ( + handle: FileHandle, + prefix: Buffer, + input: { + readonly fileSize: number; + readonly hostArchitecture: NodeJS.Architecture; + readonly signal?: AbortSignal | undefined; + }, +): Promise | null> => { + const table = readMachoLoadCommandTableHeader(prefix, input.fileSize); + if (!table.ok) return table; + if (table.value === null) return null; + const parsed = parseExecutableHeader( + prefix.subarray(0, table.value.headerSize), + input.hostArchitecture, + input.fileSize, + ); + if (!parsed.ok) return parsed; + const framed = await readMachoCommandFrames(handle, table.value, { + prefix, + fileSize: input.fileSize, + signal: input.signal, + }); + return framed.ok ? parsed : framed; +}; + +/** Walk thin command headers through one probe window, skipping payload bytes. */ +const readMachoCommandFrames = async ( + handle: FileHandle, + table: MachoLoadCommandTableHeader, + input: { + readonly prefix: Buffer; + readonly fileSize: number; + readonly fileOffset?: number | undefined; + readonly signal?: AbortSignal | undefined; + }, +): Promise> => { + const commands = new MachoLoadCommandCursor(table); + let window = input.prefix; + let windowOffset = 0; + while (commands.nextOffset !== null) { + const offset = table.headerSize + commands.nextOffset; + if ( + offset + 8 <= input.fileSize && + (offset < windowOffset || offset + 8 > windowOffset + window.length) + ) { + window = Buffer.alloc(Math.min(4096, input.fileSize - offset)); + windowOffset = offset; + const observed = await handle.read( + window, + 0, + window.length, + (input.fileOffset ?? 0) + offset, + ); + throwIfTargetResolutionCancelled(input.signal); + window = window.subarray(0, observed.bytesRead); + } + const relative = offset - windowOffset; + const frame = commands.read( + relative >= 0 ? window.subarray(relative, relative + 8) : Buffer.alloc(0), + ); + if (!frame.ok) return frame; + } + return commands.finish(); +}; + +/** Validate declared FAT slices after the canonical table/identity admission succeeds. */ +const readMachoSliceCommandFrames = async ( + handle: FileHandle, + slice: UniversalMachOSlice, + signal?: AbortSignal, +): Promise> => { + const prefix = Buffer.alloc(Math.min(4096, slice.size)); + const observed = await handle.read(prefix, 0, prefix.length, slice.offset); + throwIfTargetResolutionCancelled(signal); + const bytes = prefix.subarray(0, observed.bytesRead); + const table = readMachoLoadCommandTableHeader(bytes, slice.size); + if (!table.ok) return table; + if (table.value === null) + return err("FAT slice does not identify a thin Mach-O image"); + return readMachoCommandFrames(handle, table.value, { + prefix: bytes, + fileSize: slice.size, + fileOffset: slice.offset, + signal, + }); +}; + /** Read the validated FAT table and bounded thin-header identity prefixes. */ const readFatSliceHeaderEvidence = async ( handle: FileHandle, diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index f08b1bd302..2b90792fe6 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -1,3 +1,4 @@ +import { MachoLoadCommandCursor } from "../../domain/apple/machoLoadCommands.js"; import type { MachoDependency, MachoImageFacts, @@ -5,7 +6,6 @@ import type { } from "../../domain/apple/dylibResolution.js"; import { machoCpuSubtypeBase } from "../../domain/apple/machoCpuIdentity.js"; import { - machoLoadCommandAlignment, classifyFatContainerIdentity, readFatContainerHeader, readFatSliceDeclarations, @@ -427,7 +427,6 @@ const decodeCommands = ( )[]; readonly maxVmOffset: bigint; } => { - const view = viewOf(bytes); const slice: MachoSlice = { architecture: architectureName(header.cpuType, header.cpuSubtype), slice_offset: header.sliceOffset, @@ -455,24 +454,17 @@ const decodeCommands = ( let unreadableSegment: number | undefined; let lazyInfo = false; let lazyInfoCount = 0; - let offset = 0; - for (let index = 0; index < header.commandCount; index++) { - if (offset + 8 > bytes.byteLength) - throw new MachoFormatIssue( - "malformed", - `load command ${index} starts beyond sizeofcmds`, - ); - const command = view.getUint32(offset, true); - const size = view.getUint32(offset + 4, true); - if ( - size < 8 || - size % machoLoadCommandAlignment(header.wide) !== 0 || - offset + size > bytes.byteLength - ) - throw new MachoFormatIssue( - "malformed", - `load command ${index} at offset ${offset} has invalid cmdsize ${size}`, - ); + 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); @@ -490,13 +482,9 @@ const decodeCommands = ( } const decoded = decodeCommand(command, body, index, slice); if (decoded !== undefined) dependencies.push(decoded); - offset += size; } - if (offset !== bytes.byteLength) - throw new MachoFormatIssue( - "malformed", - `load commands declare ${header.commandCount} commands but sizeofcmds holds ${bytes.byteLength} bytes`, - ); + const complete = commands.finish(); + if (!complete.ok) throw new MachoFormatIssue("malformed", complete.error); if (lazyInfo && unreadableSegment !== undefined) throw new MachoFormatIssue( "malformed", diff --git a/src/domain/apple/machoLoadCommands.ts b/src/domain/apple/machoLoadCommands.ts new file mode 100644 index 0000000000..b31dadeb67 --- /dev/null +++ b/src/domain/apple/machoLoadCommands.ts @@ -0,0 +1,104 @@ +import { err, ok, type Result } from "../result.js"; +import { machoLoadCommandAlignment } from "./machoContainer.js"; + +/** Header commitments needed to walk commands without retaining their payloads. */ +export interface MachoLoadCommandTableHeader { + readonly headerSize: 28 | 32; + readonly wide: boolean; + readonly littleEndian: boolean; + readonly commandCount: number; + readonly commandBytes: number; +} + +/** Read only the thin header's load-command framing; other formats return null. */ +export const readMachoLoadCommandTableHeader = ( + bytes: Uint8Array, + fileSize: number, +): Result => { + if (bytes.byteLength < 4) return ok(null); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const magic = view.getUint32(0, false); + if (![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) + return ok(null); + const wide = magic === 0xfeedfacf || magic === 0xcffaedfe; + const headerSize = wide ? 32 : 28; + if (bytes.byteLength < headerSize) + return err( + `truncated Mach-O header: a ${wide ? 64 : 32}-bit header needs ${headerSize} bytes; the file has ${fileSize}`, + ); + const littleEndian = magic === 0xcefaedfe || magic === 0xcffaedfe; + const commandBytes = view.getUint32(20, littleEndian); + if (commandBytes > fileSize - headerSize) + return err( + `truncated Mach-O load commands: the header declares ${commandBytes} bytes after its ${headerSize}-byte header; the file has ${fileSize}`, + ); + return ok({ + headerSize, + wide, + littleEndian, + commandCount: view.getUint32(16, littleEndian), + commandBytes, + }); +}; + +/** One owner's count, alignment and extent proof for a sequential command walk. */ +export class MachoLoadCommandCursor { + #index = 0; + #offset = 0; + + constructor( + private readonly table: Pick< + MachoLoadCommandTableHeader, + "wide" | "littleEndian" | "commandCount" | "commandBytes" + >, + ) {} + + /** Offset relative to the command table; null when every declared row was read. */ + get nextOffset(): number | null { + return this.#index < this.table.commandCount ? this.#offset : null; + } + + /** Consume exactly one command's eight-byte header, leaving its payload to callers. */ + read(bytes: Uint8Array): Result< + { + readonly index: number; + readonly offset: number; + readonly command: number; + readonly size: number; + }, + string + > { + const { commandBytes, littleEndian, wide } = this.table; + if (this.#offset + 8 > commandBytes || bytes.byteLength < 8) + return err(`load command ${this.#index} starts beyond sizeofcmds`); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const size = view.getUint32(4, littleEndian); + if ( + size < 8 || + size % machoLoadCommandAlignment(wide) !== 0 || + size > commandBytes - this.#offset + ) + return err( + `load command ${this.#index} at offset ${this.#offset} has invalid cmdsize ${size}`, + ); + const frame = { + index: this.#index, + offset: this.#offset, + command: view.getUint32(0, littleEndian), + size, + }; + this.#index += 1; + this.#offset += size; + return ok(frame); + } + + /** A successful walk must exhaust both ncmds and sizeofcmds. */ + finish(): Result { + return this.#index === this.table.commandCount && + this.#offset === this.table.commandBytes + ? ok(null) + : err( + `load commands declare ${this.table.commandCount} commands but sizeofcmds holds ${this.table.commandBytes} bytes`, + ); + } +} diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index d9fba4436f..95e068925d 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -27,6 +27,10 @@ import { validateFatSliceIdentity, type FatSliceHeaderEvidence, } from "./apple/machoContainer.js"; +import { + MachoLoadCommandCursor, + readMachoLoadCommandTableHeader, +} from "./apple/machoLoadCommands.js"; /** Format and architecture facts recovered from an executable header. */ export type ExecutableMetadata = @@ -123,7 +127,7 @@ export const parseExecutableHeader = ( if (bytes.length < 8) return err("truncated or unsupported binary header"); const magic = bytes.readUInt32BE(0); if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) - return parseThinMachO(bytes, magic, fileSize); + return parseThinMachO(bytes, fileSize); const fatHeader = fatEvidence?.header ?? readFatContainerHeader(bytes, fileSize); if (fatHeader.state !== "not-fat") @@ -136,36 +140,35 @@ const CPU_TYPE_ARM64_32 = 0x0200000c; const parseThinMachO = ( bytes: Buffer, - magic: number, fileSize: number, ): Result => { - const is64 = magic === 0xfeedfacf || magic === 0xcffaedfe; - const headerSize = is64 ? 32 : 28; - if (bytes.length < headerSize) - return err( - `truncated Mach-O header: a ${is64 ? 64 : 32}-bit header needs ${headerSize} bytes; the file has ${fileSize}`, - ); + const table = readMachoLoadCommandTableHeader(bytes, fileSize); + if (!table.ok) return table; + if (table.value === null) return err("unsupported binary format"); const identity = readMachoHeaderPrefixFacts(bytes.subarray(0, 12)).identity; if (identity === null) return err("unsupported Mach-O architecture"); - const little = magic === 0xcefaedfe || magic === 0xcffaedfe; - const read = (offset: number): number => - little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset); const architecture = identity.cpuType === CPU_TYPE_ARM64_32 ? "arm64_32" : machArchitecture(identity.cpuType); if (architecture === undefined) return err("unsupported Mach-O architecture"); - const commandBytes = read(20); - if (headerSize + commandBytes > fileSize) - return err( - `truncated Mach-O load commands: the header declares ${commandBytes} bytes after its ${headerSize}-byte header; the file has ${fileSize}`, - ); - if (architecture === "arm64_32") - return ok({ - format: "mach-o", - architecture, - availableArchitectures: [architecture], - }); + const commands = new MachoLoadCommandCursor(table.value); + while (commands.nextOffset !== null) { + const offset = table.value.headerSize + commands.nextOffset; + // Header-only callers may retain a prefix. File-backed admission walks + // every remaining command header through its bounded reader. + if ( + offset + 8 > bytes.length && + commands.nextOffset + 8 <= table.value.commandBytes + ) + break; + const frame = commands.read(bytes.subarray(offset, offset + 8)); + if (!frame.ok) return frame; + } + if (commands.nextOffset === null) { + const complete = commands.finish(); + if (!complete.ok) return complete; + } return ok({ format: "mach-o", architecture, diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index 08fb8630bc..2f830650f9 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -42,7 +42,7 @@ describe("native Mach-O slice selection", () => { Buffer.from(machoImageWithUnknownCommand(true, 12)), "arm64", ), - ).toThrow(/Invalid load command size/u); + ).toThrow(/invalid cmdsize/u); }); it("accepts an aligned load command", () => { diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index ee0bae2b18..ac50535771 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -1,3 +1,4 @@ +import { MachoLoadCommandCursor } from "../domain/apple/machoLoadCommands.js"; import { createHash } from "node:crypto"; import type { FixupCommands } from "./AppleMachoFixups.js"; @@ -7,7 +8,6 @@ import { } from "./MachoArchitectureSelection.js"; import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js"; import { - machoLoadCommandAlignment, readFatSliceDeclarations, validateFatSlice, type FatSliceDeclaration, @@ -229,24 +229,26 @@ export const parseMachoLayout = ( chained: null, baseAddress: undefined, }; - let cursor = slice + 32; - for (let index = 0; index < commands; index++) { - if (cursor + 8 > commandEnd) throw new RangeError("Truncated load command"); + const framing = new MachoLoadCommandCursor({ + wide: true, + littleEndian: true, + commandCount: commands, + commandBytes: commandEnd - slice - 32, + }); + while (framing.nextOffset !== null) { + const cursor = slice + 32 + framing.nextOffset; + const frame = framing.read(bytes.subarray(cursor, cursor + 8)); + if (!frame.ok) throw new RangeError(frame.error); const command = { cursor, - kind: bytes.readUInt32LE(cursor), - size: bytes.readUInt32LE(cursor + 4), + kind: frame.value.command, + size: frame.value.size, }; - if ( - command.size < 8 || - command.size % machoLoadCommandAlignment(true) !== 0 || - cursor + command.size > commandEnd - ) - throw new RangeError("Invalid load command size"); if (command.kind === 0x19) collectSegment(bytes, command, state); else collectFixupCommand(bytes, command, state); - cursor += command.size; } + const complete = framing.finish(); + if (!complete.ok) throw new RangeError(complete.error); const { segments, sections, chained, binds, rebases, dylibs } = state; const offset = (address: bigint, size = 1): number => { const matches = segments.filter( diff --git a/src/native/metadata/MachoImage.ts b/src/native/metadata/MachoImage.ts index 7dd1858623..62408057bf 100644 --- a/src/native/metadata/MachoImage.ts +++ b/src/native/metadata/MachoImage.ts @@ -8,7 +8,7 @@ import { type LoadRegion, type ZeroFillMapping, } from "./BinaryContainer.js"; -import { machoLoadCommandAlignment } from "../../domain/apple/machoContainer.js"; +import { MachoLoadCommandCursor } from "../../domain/apple/machoLoadCommands.js"; /** Read thin Mach-O images, retaining the slice's exact file-backed mappings. */ export const readMachoImage = (bytes: Buffer): BinaryContainer => { @@ -26,31 +26,23 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => { 1, "Mach-O load commands", ); - if (count > Math.floor(commandBytes / 8)) - throw new BinaryFormatFailure( - "malformed", - "Mach-O command count exceeds its load-command range", - ); const mappings: FileMapping[] = []; const loadRegions: LoadRegion[] = []; const executableMappings: FileMapping[] = []; const zeroFills: ZeroFillMapping[] = []; const sections: ImageSection[] = []; let chainedFixups = false; - let offset = commandStart; - const end = commandStart + commandBytes; - for (let index = 0; index < count; index++) { - const command = reader.u32(offset); - const size = reader.u32(offset + 4); - if ( - size < 8 || - size % machoLoadCommandAlignment(bits === 64) !== 0 || - size > end - offset - ) - throw new BinaryFormatFailure( - "malformed", - "Mach-O load command size is invalid or truncated", - ); + const commands = new MachoLoadCommandCursor({ + wide: bits === 64, + littleEndian: little, + commandCount: count, + commandBytes, + }); + while (commands.nextOffset !== null) { + const offset = commandStart + commands.nextOffset; + const frame = commands.read(bytes.subarray(offset, offset + 8)); + if (!frame.ok) throw new BinaryFormatFailure("malformed", frame.error); + const { command, size } = frame.value; if (command === 0x80000034) chainedFixups = true; if (command === 1 || command === 0x19) { const wide = command === 0x19; @@ -143,13 +135,9 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => { ); } } - offset += size; } - if (offset !== end) - throw new BinaryFormatFailure( - "malformed", - "Mach-O load-command byte count does not match its commands", - ); + const complete = commands.finish(); + if (!complete.ok) throw new BinaryFormatFailure("malformed", complete.error); const cpu = reader.u32(4); const names = new Map([ [7, "386"], diff --git a/tests/boundary/filesystem/machoCommandAdmission.test.ts b/tests/boundary/filesystem/machoCommandAdmission.test.ts new file mode 100644 index 0000000000..9edd6d5320 --- /dev/null +++ b/tests/boundary/filesystem/machoCommandAdmission.test.ts @@ -0,0 +1,152 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { parseExecutableHeader } from "../../../src/domain/binaryTarget.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { BinaryTargetError } from "../../../src/domain/configurationErrors.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + CPU, + fatImage, +} from "../../../src/artifacts/apple/MachoImage.fixture.js"; + +const commandImage = ( + wide: boolean, + littleEndian: boolean, + sizes: readonly number[], +): Buffer => { + const magic = wide + ? littleEndian + ? 0xcffaedfe + : 0xfeedfacf + : littleEndian + ? 0xcefaedfe + : 0xfeedface; + const header = thinMach(magic, wide ? 0x0100000c : 7); + const bytes = Buffer.alloc( + header.length + sizes.reduce((total, size) => total + size, 0), + ); + header.copy(bytes); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + view.setUint32(16, sizes.length, littleEndian); + view.setUint32(20, bytes.length - header.length, littleEndian); + let offset = header.length; + for (const size of sizes) { + view.setUint32(offset, 0x7fffffff, littleEndian); // Unknown commands still have framing. + view.setUint32(offset + 4, size, littleEndian); + offset += size; + } + return bytes; +}; + +it.each([ + { label: "undersized", field: 36, value: 4 }, + { label: "unaligned", field: 36, value: 12 }, + { label: "outside table", field: 36, value: 0xffffffff }, + { label: "too many commands", field: 16, value: 2 }, + { label: "unclaimed table bytes", field: 16, value: 0 }, + { label: "trailing table bytes", field: 36, value: 8 }, +])( + "rejects $label thin command framing before provider admission", + async ({ field, value }) => { + const bytes = commandImage(true, true, [16]); + bytes.writeUInt32LE(value, field); + const path = join( + await createTestTempDirectory("rea-macho-command-"), + "image", + ); + await writeFile(path, bytes); + expect(parseExecutableHeader(bytes, "arm64").ok).toBe(false); + const resolved = await parseBinaryTarget(path); + expect(resolved.ok).toBe(false); + if (resolved.ok || !(resolved.error instanceof BinaryTargetError)) + throw new Error("Expected target format refusal"); + expect(projectAnalysisError(resolved.error)).toMatchObject({ + code: "target_unavailable", + category: "unsupported_target", + }); + expect(resolved.error.reason).toMatch(/load command/iu); + }, +); + +it.each([ + { wide: false, littleEndian: false, size: 12 }, + { wide: false, littleEndian: true, size: 12 }, + { wide: true, littleEndian: false, size: 16 }, + { wide: true, littleEndian: true, size: 16 }, +])( + "retains width-specific alignment and endian controls ($wide/$littleEndian)", + async ({ wide, littleEndian, size }) => { + const bytes = commandImage(wide, littleEndian, [size]); + const path = join( + await createTestTempDirectory("rea-macho-command-control-"), + "image", + ); + await writeFile(path, bytes); + expect(await parseBinaryTarget(path)).toMatchObject({ + ok: true, + value: { format: "mach-o", architecture: wide ? "arm64" : "x86" }, + }); + }, +); + +it.each([false, true])( + "walks command headers beyond the probe window (corrupt=%s)", + async (corrupt) => { + const bytes = commandImage(true, true, [4104, 16]); + if (corrupt) bytes.writeUInt32LE(0xffffffff, 32 + 4104 + 4); + const path = join( + await createTestTempDirectory("rea-macho-command-window-"), + "image", + ); + await writeFile(path, bytes); + const resolved = await parseBinaryTarget(path); + expect(resolved.ok).toBe(!corrupt); + if (!resolved.ok) { + if (!(resolved.error instanceof BinaryTargetError)) throw resolved.error; + expect(resolved.error.reason).toContain("load command 1"); + } + }, +); + +it.each([null, 0, 1])( + "checks every admitted FAT slice's framing (corrupt slice %s)", + async (corruptIndex) => { + const slices = [ + commandImage(true, true, [16]), + commandImage(true, true, [16]), + ]; + const x86 = slices[0]!; + x86.writeUInt32LE(CPU.x86_64.type, 4); + x86.writeUInt32LE(CPU.x86_64.subtype, 8); + if (corruptIndex !== null) + slices[corruptIndex]!.writeUInt32LE(0xffffffff, 36); + const bytes = fatImage([ + { cpu: CPU.x86_64, bytes: x86 }, + { cpu: CPU.arm64, bytes: slices[1]! }, + ]); + const path = join( + await createTestTempDirectory("rea-fat-command-"), + "image", + ); + await writeFile(path, bytes); + const resolved = await parseBinaryTarget(path, { + hostArchitecture: "arm64", + }); + expect(resolved.ok).toBe(corruptIndex === null); + if (!resolved.ok) { + if (!(resolved.error instanceof BinaryTargetError)) throw resolved.error; + expect(resolved.error.reason).toContain("invalid cmdsize"); + expect(projectAnalysisError(resolved.error)).toMatchObject({ + code: "target_unavailable", + }); + } else + expect(resolved.value).toMatchObject({ + format: "mach-o-universal", + architecture: "arm64", + }); + }, +);