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.<variant>.

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 <noreply@anthropic.com>

* fix(native): share Mach-O slice preference across inspections

* chore: preserve upstream process capture test during merge

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Hemanth Rayudu 2026-10-13 03:36:23 -05:00 committed by GitHub
parent b69d20a300
commit 96fc84f1fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 328 additions and 59 deletions

View File

@ -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", () => {

View File

@ -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<Record<string, number>> = {
x86: 7,
x86_64: 0x01000007,
arm: 12,
arm64: 0x0100000c,
};
const MH_MAGIC = 0xfeedface;
const MH_CIGAM = 0xcefaedfe;

View File

@ -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<string, MachoArchitecturePolicy>
> = {
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));
};

View File

@ -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", () => {

View File

@ -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 };
};

View File

@ -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(
{},

View File

@ -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.

View File

@ -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)

View File

@ -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)