From 96fc84f1fb7c8bc69f66cd899896f4de9061fead Mon Sep 17 00:00:00 2001 From: Hemanth Rayudu Date: Tue, 13 Oct 2026 03:36:23 -0500 Subject: [PATCH] fix(native): select one slice when universal Mach-O slices share a CPU type (#2040) * fix(native): select one slice when universal Mach-O slices share a CPU type On macOS 27 most system binaries are universal files whose slices share CPU_TYPE_ARM64: arm64e + arm64e.x1 (Calculator, TextEdit), x86_64 + arm64e + arm64e.x1 (/bin/ls), or arm64 + arm64e.x1 (ShortcutDroplet). Target admission names slices by CPU family, so these targets have availableArchitectures ["arm64"] while the file holds two arm64 slices. inspect_macho read that family count as a slice count and ran otool, nm, dyld_info, dwarfdump and vtool without -arch. ShortcutDroplet failed with "Malformed otool load-command output" (40 load commands against ncmds 20). Calculator reported both slices' imports (4138 instead of 2069), nm's "(for architecture ...)" header lines as exports, and no selected slice. selectArchitecture now also filters when lipo lists more than one slice. Its preference is unchanged: the family name, then arm64e, then the first arm64e.. inspect_native_dispatch_metadata matched FAT slices by CPU type only and refused /bin/ls and TextEdit as "Ambiguous FAT architecture slice". selectMachoSlice now ranks slices of the requested CPU type by base subtype in the same order: arm64 (0, then 1) or x86_64 (3), then arm64e (2), then other variants such as arm64e.x1 in FAT table order. It reports ambiguity only when the top-ranked slices share a subtype. Co-Authored-By: Claude Opus 5.5 * fix(native): share Mach-O slice preference across inspections * chore: preserve upstream process capture test during merge --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/native/AppleMachoSelection.test.ts | 48 ++++++++++- src/native/AppleMachoSelection.ts | 57 ++++++++----- src/native/MachoArchitectureSelection.ts | 54 ++++++++++++ src/native/NativeMachoInspection.test.ts | 58 +++++++++++++ src/native/NativeMachoInspection.ts | 33 ++++---- .../native/nativeSelectedArchitecture.test.ts | 84 ++++++++++++++----- .../native-macos/lipo-same-cpu/README.md | 19 +++++ .../native-macos/lipo-same-cpu/calculator.txt | 17 ++++ .../lipo-same-cpu/shortcut-droplet.txt | 17 ++++ 9 files changed, 328 insertions(+), 59 deletions(-) create mode 100644 src/native/MachoArchitectureSelection.ts create mode 100644 tests/fixtures/native-macos/lipo-same-cpu/README.md create mode 100644 tests/fixtures/native-macos/lipo-same-cpu/calculator.txt create mode 100644 tests/fixtures/native-macos/lipo-same-cpu/shortcut-droplet.txt 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)