diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index 5c19026e0..af2d0be55 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -7,9 +7,13 @@ import { fatImage, machoImage, } from "../artifacts/apple/MachoImage.fixture.js"; -import { parseMachoLayout } from "./AppleMachoSelection.js"; +import { parseMachoLayout, selectMachoSlice } from "./AppleMachoSelection.js"; import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js"; +/** macOS 27 system binaries ship CPU_SUBTYPE_ARM64E_X1 (12) with the PAC ABI bit. */ +const ARM64E_X1 = { type: 0x0100000c, subtype: 0x8000000c } as const; +const X86_64H = { type: 0x01000007, subtype: 8 } as const; + describe("native Mach-O slice selection", () => { it("preserves high-bit section-name bytes instead of ASCII-folding metadata names", () => { const segment = Buffer.alloc(152); @@ -68,6 +72,48 @@ describe("native Mach-O slice selection", () => { /not aligned/u, ); }); + + it.each([ + { layout: "arm64e, arm64e.x1", cpus: [CPU.arm64e, ARM64E_X1], index: 0 }, + { layout: "arm64e.x1, arm64e", cpus: [ARM64E_X1, CPU.arm64e], index: 1 }, + { layout: "arm64, arm64e.x1", cpus: [CPU.arm64, ARM64E_X1], index: 0 }, + { layout: "arm64e.x1, arm64", cpus: [ARM64E_X1, CPU.arm64], index: 1 }, + { + layout: "x86_64, arm64e, arm64e.x1", + cpus: [CPU.x86_64, CPU.arm64e, ARM64E_X1], + index: 1, + }, + { + layout: "x86_64h, x86_64", + cpus: [X86_64H, CPU.x86_64], + index: 1, + architecture: "x86_64", + }, + ])( + "selects one slice when FAT slices $layout share a CPU type", + ({ cpus, index, architecture = "arm64" }) => { + const fat = Buffer.from( + fatImage(cpus.map((cpu) => ({ cpu, bytes: machoImage({ cpu }) }))), + ); + expect(selectMachoSlice(fat, architecture).fatDeclaration).toMatchObject({ + index, + cpuSubtype: cpus[index]?.subtype, + }); + expect(parseMachoLayout(fat, architecture).segments).toEqual([]); + }, + ); + + it("rejects slices of the requested CPU type that share a subtype", () => { + const fat = fatImage( + [CPU.arm64e, CPU.arm64e].map((cpu) => ({ + cpu, + bytes: machoImage({ cpu }), + })), + ); + expect(() => parseMachoLayout(Buffer.from(fat), "arm64")).toThrow( + /Ambiguous FAT architecture slice/u, + ); + }); }); describe("native Mach-O fixup load command boundaries", () => { diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 9ee93f995..e63ec082d 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -1,7 +1,13 @@ import type { FixupCommands } from "./AppleMachoFixups.js"; +import { + MACHO_ARCHITECTURE_POLICIES, + baseMachoCpuSubtype, + machoSubtypeRank, +} from "./MachoArchitectureSelection.js"; import { readFatSliceDeclarations, validateFatSlice, + type FatSliceDeclaration, } from "../domain/apple/machoContainer.js"; /** One mapped Mach-O segment with file-backed VA range. */ @@ -90,32 +96,44 @@ export const selectMachoSlice = ( const headerEnd = 8 + count * stride; if (count > 128 || headerEnd > bytes.length) throw malformed("Malformed FAT architecture table"); - const cpu = MACHO_CPU_TYPE[architecture]; - if (cpu === undefined) throw invalid("Requested FAT architecture is absent"); + const policy = MACHO_ARCHITECTURE_POLICIES[architecture]; + if (policy === undefined) + throw invalid("Requested FAT architecture is absent"); const declarations = readFatSliceDeclarations(bytes.subarray(8, headerEnd), { count, wide: fat64, littleEndian, }); if (declarations.status !== "parsed") throw malformed(declarations.reason); - let selected: MachoSlice | undefined; + let best: FatSliceDeclaration | undefined; + let bestRank = Number.POSITIVE_INFINITY; + let ambiguous = false; for (const declaration of declarations.slices) { const issue = validateFatSlice(declaration, bytes.length, headerEnd); if (issue !== null) throw malformed(issue); - if (declaration.cpuType !== cpu) continue; - if (selected !== undefined) - throw invalid("Ambiguous FAT architecture slice"); - selected = { - slice: declaration.offset, - sliceEnd: declaration.offset + declaration.size, - fatDeclaration: { - ...declaration, - tableEnd: headerEnd, - }, - }; + if (declaration.cpuType !== policy.cpuType) continue; + const rank = machoSubtypeRank(policy, declaration.cpuSubtype); + if (best !== undefined && rank >= bestRank) { + // Equal ranks keep FAT table order unless the slices share a subtype. + if ( + rank === bestRank && + baseMachoCpuSubtype(declaration.cpuSubtype) === + baseMachoCpuSubtype(best.cpuSubtype) + ) + ambiguous = true; + continue; + } + best = declaration; + bestRank = rank; + ambiguous = false; } - if (selected === undefined) - throw invalid("Requested FAT architecture is absent"); + if (best === undefined) throw invalid("Requested FAT architecture is absent"); + if (ambiguous) throw invalid("Ambiguous FAT architecture slice"); + const selected: MachoSlice = { + slice: best.offset, + sliceEnd: best.offset + best.size, + fatDeclaration: { ...best, tableEnd: headerEnd }, + }; if (selected.slice + 4 > bytes.length) throw malformed("Truncated Mach-O header"); const sliceMagic = bytes.readUInt32LE(selected.slice); @@ -126,13 +144,6 @@ export const selectMachoSlice = ( return selected; }; -/** CPU types admitted by `machArchitecture` in the domain binary target parser. */ -const MACHO_CPU_TYPE: Readonly> = { - x86: 7, - x86_64: 0x01000007, - arm: 12, - arm64: 0x0100000c, -}; const MH_MAGIC = 0xfeedface; const MH_CIGAM = 0xcefaedfe; diff --git a/src/native/MachoArchitectureSelection.ts b/src/native/MachoArchitectureSelection.ts new file mode 100644 index 000000000..b6e6dcec4 --- /dev/null +++ b/src/native/MachoArchitectureSelection.ts @@ -0,0 +1,54 @@ +interface MachoArchitecturePolicy { + readonly cpuType: number; + readonly subtypes: readonly { + readonly subtype: number; + readonly name: string; + }[]; + readonly variantName?: RegExp; +} + +/** One preference order for FAT subtype identities and Apple tool slice names. */ +export const MACHO_ARCHITECTURE_POLICIES: Readonly< + Record +> = { + x86: { cpuType: 7, subtypes: [{ subtype: 3, name: "i386" }] }, + x86_64: { + cpuType: 0x01000007, + subtypes: [{ subtype: 3, name: "x86_64" }], + }, + arm: { cpuType: 12, subtypes: [], variantName: /^arm$/u }, + arm64: { + cpuType: 0x0100000c, + subtypes: [ + { subtype: 0, name: "arm64" }, + { subtype: 2, name: "arm64e" }, + { subtype: 1, name: "arm64v8" }, + ], + variantName: /^arm64e\.[A-Za-z0-9_]+$/u, + }, +}; + +/** High subtype bits carry capabilities, such as the arm64e PAC ABI version. */ +export const baseMachoCpuSubtype = (subtype: number): number => + subtype & 0x00ffffff; + +/** Unlisted subtypes follow the preferred slices in FAT table order. */ +export const machoSubtypeRank = ( + policy: MachoArchitecturePolicy, + subtype: number, +): number => { + const rank = policy.subtypes.findIndex( + (candidate) => candidate.subtype === baseMachoCpuSubtype(subtype), + ); + return rank < 0 ? policy.subtypes.length : rank; +}; + +/** Select a tool spelling using the same preference order as FAT selection. */ +export const selectMachoArchitectureName = ( + policy: MachoArchitecturePolicy, + available: readonly string[], +): string | undefined => { + for (const { name } of policy.subtypes) + if (available.includes(name)) return name; + return available.find((name) => policy.variantName?.test(name)); +}; diff --git a/src/native/NativeMachoInspection.test.ts b/src/native/NativeMachoInspection.test.ts index 6fe24eb90..7ddff2491 100644 --- a/src/native/NativeMachoInspection.test.ts +++ b/src/native/NativeMachoInspection.test.ts @@ -1,10 +1,18 @@ import { describe, expect, it } from "vitest"; +import { + CPU, + LC, + dylibCommand, + fatImage, + machoImage, +} from "../artifacts/apple/MachoImage.fixture.js"; import { AnalysisCancelledError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import { err, ok } from "../domain/result.js"; +import { parseMachoLayout, selectMachoSlice } from "./AppleMachoSelection.js"; import type { NativeCommandCapture } from "./CommandRunner.js"; import { inspectNativeMacho } from "./NativeMachoInspection.js"; import { @@ -78,6 +86,56 @@ describe("Mach-O tool architecture selection", () => { "/fixture/app", ]); }); + + it.each([ + { names: ["arm64v8", "arm64e"], selected: "arm64e" }, + { names: ["arm64e", "arm64v8"], selected: "arm64e" }, + { names: ["arm64v8", "arm64e", "arm64"], selected: "arm64" }, + { names: ["arm64v8"], selected: "arm64v8" }, + { names: ["arm64v8", "arm64e.x1"], selected: "arm64v8" }, + { names: ["arm64e.x1", "arm64v8"], selected: "arm64v8" }, + ] as const)( + "keeps inspection and dispatch on $selected for FAT order $names", + async ({ names, selected }) => { + const cpus = { + arm64: CPU.arm64, + arm64v8: { type: CPU.arm64.type, subtype: 1 }, + arm64e: CPU.arm64e, + "arm64e.x1": { type: CPU.arm64.type, subtype: 0x8000000c }, + }; + const slices = names.map((name) => { + const cpu = cpus[name]; + return { + cpu, + bytes: machoImage({ + cpu, + commands: [dylibCommand(LC.LOAD_DYLIB, `/fixture/${name}.dylib`)], + }), + }; + }); + const fat = Buffer.from(fatImage(slices)); + const lipo = names + .map( + (name, index) => + `architecture ${name}\n cputype ${cpus[name].type}\n cpusubtype ${cpus[name].subtype}\n offset ${(index + 1) * 4096}\n size ${slices[index]?.bytes.length}\n align 2^12\n`, + ) + .join(""); + const { commands } = await inspect(lipo, "arm64"); + expect(commands.find(({ tool }) => tool === "otool")?.args).toEqual([ + "-h", + "-l", + "-arch", + selected, + "/fixture/app", + ]); + expect(selectMachoSlice(fat, "arm64").slice).toBe( + (names.findIndex((name) => name === selected) + 1) * 4096, + ); + expect(parseMachoLayout(fat, "arm64").fixups.dylibs).toEqual([ + `/fixture/${selected}.dylib`, + ]); + }, + ); }); describe("Mach-O UUID reconciliation", () => { diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 24ccb2514..836c3de1e 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -9,6 +9,10 @@ import { import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import type { NativeCommandCapture } from "./CommandRunner.js"; +import { + MACHO_ARCHITECTURE_POLICIES, + selectMachoArchitectureName, +} from "./MachoArchitectureSelection.js"; import { parseDyldSymbols } from "./parsers/dyldInfo.js"; import { parseLipoArchitectures } from "./parsers/lipo.js"; import { withoutEchoedPathHeader } from "./parsers/echoedPath.js"; @@ -399,23 +403,22 @@ const selectArchitecture = ( target: BinaryTarget, available: readonly string[], ): ArchitectureSelection => { - if (target.kind !== "executable" || target.availableArchitectures.length < 2) + // BinaryTarget lists CPU families and lipo lists slices. A universal file + // can hold several slices of one family (arm64e and arm64e.x1), and slice + // tools without -arch print every slice. + if ( + target.kind !== "executable" || + (target.availableArchitectures.length < 2 && available.length < 2) + ) return { status: "unfiltered" }; const normalized = target.architecture === "x86" ? "i386" : target.architecture; - if (available.includes(normalized)) - return { status: "selected", architecture: normalized }; - // BinaryTarget intentionally normalizes ARM64 CPU subtypes. Recover the - // concrete arm64e slice name from lipo before invoking slice-aware tools. - if (normalized === "arm64") { - const arm64e = available.find((architecture) => architecture === "arm64e"); - if (arm64e !== undefined) - return { status: "selected", architecture: arm64e }; - const variant = available.find((architecture) => - /^arm64e\.[A-Za-z0-9_]+$/u.test(architecture), - ); - if (variant !== undefined) - return { status: "selected", architecture: variant }; - } + const policy = MACHO_ARCHITECTURE_POLICIES[target.architecture]; + const selected = + policy === undefined + ? available.find((name) => name === normalized) + : selectMachoArchitectureName(policy, available); + if (selected !== undefined) + return { status: "selected", architecture: selected }; return { status: "absent", architecture: normalized }; }; diff --git a/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts b/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts index 006b13003..90b598f5d 100644 --- a/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts +++ b/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts @@ -1,7 +1,10 @@ import { expect, it } from "vitest"; import { inspectMachoSchema } from "../../../../src/domain/native/nativeInspection.js"; import { err } from "../../../../src/domain/result.js"; -import { NativeCommandFailure } from "../../../../src/native/CommandRunner.js"; +import { + NativeCommandFailure, + type NativeCommandRunner, +} from "../../../../src/native/CommandRunner.js"; import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; import { NativeFixtureRunner, @@ -9,6 +12,35 @@ import { nativeMachoTarget, } from "../../../fixtures/nativeCommands.js"; +/** Replay fixtures, failing any slice command that does not name the expected slice. */ +const sliceSelectingRunner = ( + fixtures: NativeFixtureRunner, + selected: string, + dyldArchitecture: string, + calls: { + readonly tool: string; + readonly arguments_: readonly string[]; + }[] = [], +): NativeCommandRunner => ({ + run(tool, arguments_) { + calls.push({ tool, arguments_ }); + const expected = tool === "dyld_info" ? dyldArchitecture : selected; + if ( + !["file", "lipo"].includes(tool) && + !arguments_.some( + (argument, index) => + argument === expected || + argument === `--arch=${expected}` || + (argument === "-arch" && arguments_[index + 1] === expected), + ) + ) + return Promise.resolve( + err(new NativeCommandFailure(tool, "nonzero-exit", 1)), + ); + return fixtures.run(tool, arguments_); + }, +}); + it("does not attribute the first universal slice's UUID and segments to the selected target", async () => { const singleSlice = await nativeFixture("otool-load.txt"); const selected = new NativeFixtureRunner(); @@ -77,25 +109,7 @@ it.each([ }); const client = new NativeMacOSProvider( {}, - { - run(tool, arguments_) { - calls.push({ tool, arguments_ }); - const expected = tool === "dyld_info" ? dyldArchitecture : selected; - if ( - !["file", "lipo"].includes(tool) && - !arguments_.some( - (argument, index) => - argument === expected || - argument === `--arch=${expected}` || - (argument === "-arch" && arguments_[index + 1] === expected), - ) - ) - return Promise.resolve( - err(new NativeCommandFailure(tool, "nonzero-exit", 1)), - ); - return fixtures.run(tool, arguments_); - }, - }, + sliceSelectingRunner(fixtures, selected, dyldArchitecture, calls), "darwin", ).createClient(nativeMachoTarget("/owned/universal")); @@ -116,6 +130,36 @@ it.each([ }, ); +it.each([ + { capture: "calculator.txt", selected: "arm64e" }, + { capture: "shortcut-droplet.txt", selected: "arm64" }, +])( + "selects one slice when every universal slice has the target's CPU type ($capture)", + async ({ capture, selected }) => { + const base = nativeMachoTarget("/owned/universal"); + if (base.kind !== "executable") + throw new Error("Expected executable fixture"); + // Admission names arm64, arm64e and arm64e.x1 slices by one CPU family. + const target = { ...base, availableArchitectures: ["arm64"] as const }; + const fixtures = new NativeFixtureRunner({ + lipo: await nativeFixture(`lipo-same-cpu/${capture}`), + }); + const result = await new NativeMacOSProvider( + {}, + sliceSelectingRunner(fixtures, selected, selected), + "darwin", + ) + .createClient(target) + .execute("inspect_macho", {}); + + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + selected_architecture: { name: selected, container_file_offset: 16384 }, + architectures: { total: 2 }, + }); + }, +); + it("projects universal segment evidence into the selected slice's container offsets", async () => { const client = new NativeMacOSProvider( {}, diff --git a/tests/fixtures/native-macos/lipo-same-cpu/README.md b/tests/fixtures/native-macos/lipo-same-cpu/README.md new file mode 100644 index 000000000..d376e3b5d --- /dev/null +++ b/tests/fixtures/native-macos/lipo-same-cpu/README.md @@ -0,0 +1,19 @@ +# Universal Mach-O captures whose slices share one CPU type + +Captured on macOS 27.0.1 (26A434), arm64, from two system executables whose +FAT slices all use `CPU_TYPE_ARM64`: + +```sh +lipo -detailed_info /System/Applications/Calculator.app/Contents/MacOS/Calculator > calculator.txt +lipo -detailed_info /System/Library/CoreServices/ShortcutDroplet.app/Contents/MacOS/ShortcutDroplet > shortcut-droplet.txt +``` + +Calculator holds `arm64e` (subtype `0x80000002`) and `arm64e.x1` (subtype +`0x8000000c`); ShortcutDroplet holds `arm64` (subtype `0`) and `arm64e.x1`. +Target admission names both files by the single CPU family `arm64`, while lipo +lists two slices. + +The executable path on the `Fat header in:` line was replaced with `fixture`, +and trailing whitespace (lipo prints `capabilities ` for a slice without +capabilities) and the final blank line were removed. Tool fields are otherwise +unchanged. diff --git a/tests/fixtures/native-macos/lipo-same-cpu/calculator.txt b/tests/fixtures/native-macos/lipo-same-cpu/calculator.txt new file mode 100644 index 000000000..8e90b4902 --- /dev/null +++ b/tests/fixtures/native-macos/lipo-same-cpu/calculator.txt @@ -0,0 +1,17 @@ +Fat header in: fixture +fat_magic 0xcafebabe +nfat_arch 2 +architecture arm64e + cputype CPU_TYPE_ARM64 + cpusubtype CPU_SUBTYPE_ARM64E + capabilities PTR_AUTH_VERSION USERSPACE 0 + offset 16384 + size 1727552 + align 2^14 (16384) +architecture arm64e.x1 + cputype CPU_TYPE_ARM64 + cpusubtype unknown arm64 cpusubtype + capabilities PTR_AUTH_VERSION USERSPACE 0 + offset 1753088 + size 1711088 + align 2^14 (16384) diff --git a/tests/fixtures/native-macos/lipo-same-cpu/shortcut-droplet.txt b/tests/fixtures/native-macos/lipo-same-cpu/shortcut-droplet.txt new file mode 100644 index 000000000..252ff7234 --- /dev/null +++ b/tests/fixtures/native-macos/lipo-same-cpu/shortcut-droplet.txt @@ -0,0 +1,17 @@ +Fat header in: fixture +fat_magic 0xcafebabe +nfat_arch 2 +architecture arm64 + cputype CPU_TYPE_ARM64 + cpusubtype CPU_SUBTYPE_ARM64_ALL + capabilities + offset 16384 + size 52000 + align 2^14 (16384) +architecture arm64e.x1 + cputype CPU_TYPE_ARM64 + cpusubtype unknown arm64 cpusubtype + capabilities PTR_AUTH_VERSION USERSPACE 0 + offset 81920 + size 52000 + align 2^14 (16384)