fix(native): rank Mach-O slices by parsed CPU subtype

This commit is contained in:
morluto 2026-10-13 16:43:12 +08:00
parent 96fc84f1fb
commit c02fb0ea80
8 changed files with 118 additions and 29 deletions

View File

@ -8,11 +8,11 @@ import {
type NativeCommandRunner,
} from "../native/CommandRunner.js";
import {
lipoSubtypeBits,
parseLipoArchitectures,
type LipoArchitecture,
} from "../native/parsers/lipo.js";
import type { MachoSlice } from "../domain/apple/dylibResolution.js";
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { readMachoImage, type ReadAt } from "./apple/MachoLoadCommandReader.js";
import {
@ -471,8 +471,10 @@ const lipoSubtypeMatches = (
): boolean => {
if (reported.cpu_subtype_code !== null)
return reported.cpu_subtype_code === observedCpuSubtype;
const bits = lipoSubtypeBits(reported.cpu_subtype);
return bits === null || bits === (observedCpuSubtype & 0x00ffffff);
return (
reported.cpu_subtype_base === null ||
reported.cpu_subtype_base === machoCpuSubtypeBase(observedCpuSubtype)
);
};
/**

View File

@ -1,9 +1,9 @@
import type { FixupCommands } from "./AppleMachoFixups.js";
import {
MACHO_ARCHITECTURE_POLICIES,
baseMachoCpuSubtype,
machoSubtypeRank,
} from "./MachoArchitectureSelection.js";
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import {
readFatSliceDeclarations,
validateFatSlice,
@ -117,8 +117,8 @@ export const selectMachoSlice = (
// Equal ranks keep FAT table order unless the slices share a subtype.
if (
rank === bestRank &&
baseMachoCpuSubtype(declaration.cpuSubtype) ===
baseMachoCpuSubtype(best.cpuSubtype)
machoCpuSubtypeBase(declaration.cpuSubtype) ===
machoCpuSubtypeBase(best.cpuSubtype)
)
ambiguous = true;
continue;

View File

@ -1,3 +1,6 @@
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import type { LipoArchitecture } from "./parsers/lipo.js";
interface MachoArchitecturePolicy {
readonly cpuType: number;
readonly subtypes: readonly {
@ -28,17 +31,13 @@ export const MACHO_ARCHITECTURE_POLICIES: Readonly<
},
};
/** 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),
(candidate) => candidate.subtype === machoCpuSubtypeBase(subtype),
);
return rank < 0 ? policy.subtypes.length : rank;
};
@ -46,9 +45,36 @@ export const machoSubtypeRank = (
/** Select a tool spelling using the same preference order as FAT selection. */
export const selectMachoArchitectureName = (
policy: MachoArchitecturePolicy,
available: readonly string[],
available: readonly LipoArchitecture[],
): string | undefined => {
for (const { name } of policy.subtypes)
if (available.includes(name)) return name;
return available.find((name) => policy.variantName?.test(name));
let selected: string | undefined;
let bestRank = Number.POSITIVE_INFINITY;
for (const architecture of available) {
if (
architecture.cpu_type_code !== null &&
architecture.cpu_type_code !== policy.cpuType
)
continue;
const namedSubtype = policy.subtypes.find(
({ name }) => name === architecture.name,
);
const recognizedName =
namedSubtype !== undefined || policy.variantName?.test(architecture.name);
if (
!recognizedName &&
(architecture.cpu_type_code === null ||
architecture.cpu_subtype_base === null)
)
continue;
// Parsed identity is authoritative. Names support thin/partial lipo records.
const subtype = architecture.cpu_subtype_base ?? namedSubtype?.subtype;
const rank =
subtype === undefined
? policy.subtypes.length
: machoSubtypeRank(policy, subtype);
if (rank >= bestRank) continue;
selected = architecture.name;
bestRank = rank;
}
return selected;
};

View File

@ -94,6 +94,8 @@ describe("Mach-O tool architecture selection", () => {
{ names: ["arm64v8"], selected: "arm64v8" },
{ names: ["arm64v8", "arm64e.x1"], selected: "arm64v8" },
{ names: ["arm64e.x1", "arm64v8"], selected: "arm64v8" },
{ names: ["arm64v8", "arm64e.v1"], selected: "arm64e.v1" },
{ names: ["arm64e.v1", "arm64v8"], selected: "arm64e.v1" },
] as const)(
"keeps inspection and dispatch on $selected for FAT order $names",
async ({ names, selected }) => {
@ -102,6 +104,7 @@ describe("Mach-O tool architecture selection", () => {
arm64v8: { type: CPU.arm64.type, subtype: 1 },
arm64e: CPU.arm64e,
"arm64e.x1": { type: CPU.arm64.type, subtype: 0x8000000c },
"arm64e.v1": { type: CPU.arm64.type, subtype: 0x81000002 },
};
const slices = names.map((name) => {
const cpu = cpus[name];
@ -125,9 +128,12 @@ describe("Mach-O tool architecture selection", () => {
"-h",
"-l",
"-arch",
selected,
selected.split(".")[0],
"/fixture/app",
]);
expect(commands.find(({ tool }) => tool === "dyld_info")?.args).toContain(
selected,
);
expect(selectMachoSlice(fat, "arm64").slice).toBe(
(names.findIndex((name) => name === selected) + 1) * 4096,
);

View File

@ -14,7 +14,10 @@ import {
selectMachoArchitectureName,
} from "./MachoArchitectureSelection.js";
import { parseDyldSymbols } from "./parsers/dyldInfo.js";
import { parseLipoArchitectures } from "./parsers/lipo.js";
import {
parseLipoArchitectures,
type LipoArchitecture,
} from "./parsers/lipo.js";
import { withoutEchoedPathHeader } from "./parsers/echoedPath.js";
import { parseOtoolLoadCommands } from "./parsers/otool.js";
import { reconcileMachoUuid } from "./parsers/machoUuid.js";
@ -72,8 +75,7 @@ export const inspectNativeMacho = async (
const lipo = captures.find(({ tool }) => tool === "lipo");
if (lipo === undefined) throw new TypeError("Missing lipo capture");
const architectures = parseLipoArchitectures(lipo.stdout);
const availableArchitectures = architectures.map(({ name }) => name);
const selection = selectArchitecture(context.target, availableArchitectures);
const selection = selectArchitecture(context.target, architectures);
if (selection.status === "absent")
return err(
new AnalysisOutputError(
@ -401,7 +403,7 @@ type ArchitectureSelection =
const selectArchitecture = (
target: BinaryTarget,
available: readonly string[],
available: readonly LipoArchitecture[],
): ArchitectureSelection => {
// BinaryTarget lists CPU families and lipo lists slices. A universal file
// can hold several slices of one family (arm64e and arm64e.x1), and slice
@ -416,7 +418,7 @@ const selectArchitecture = (
const policy = MACHO_ARCHITECTURE_POLICIES[target.architecture];
const selected =
policy === undefined
? available.find((name) => name === normalized)
? available.find(({ name }) => name === normalized)?.name
: selectMachoArchitectureName(policy, available);
if (selected !== undefined)
return { status: "selected", architecture: selected };

View File

@ -59,6 +59,7 @@ architecture arm64e
cpu_type_code: null,
cpu_subtype: "CPU_SUBTYPE_FUTURE",
cpu_subtype_code: null,
cpu_subtype_base: null,
},
]);
});
@ -85,8 +86,29 @@ architecture arm64e
{
cpu_subtype: "CPU_SUBTYPE_X86_ALL",
cpu_subtype_code: null,
cpu_subtype_base: 3,
capabilities: null,
},
]);
});
it.each([
{ capabilities: "PTR_AUTH_VERSION USERSPACE 1", code: 0x81000002 },
{ capabilities: "", code: null },
])(
"retains an arm64e base subtype with capabilities '$capabilities'",
({ capabilities, code }) => {
expect(
parseLipoArchitectures(
`architecture arm64e.v1\n cputype CPU_TYPE_ARM64\n cpusubtype CPU_SUBTYPE_ARM64E\n capabilities ${capabilities}\n`,
),
).toMatchObject([
{
cpu_type_code: 0x0100000c,
cpu_subtype_code: code,
cpu_subtype_base: 2,
},
]);
},
);
});

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { machoCpuSubtypeBase } from "../../domain/apple/machoCpuIdentity.js";
const architectureSchema = z.object({
name: z.string().min(1),
@ -6,6 +7,7 @@ const architectureSchema = z.object({
cpu_subtype: z.string().nullable(),
cpu_type_code: z.number().int().nonnegative().nullable(),
cpu_subtype_code: z.number().int().nonnegative().nullable(),
cpu_subtype_base: z.number().int().nonnegative().nullable(),
capabilities: z.string().nullable(),
file_offset: z.number().int().min(0).nullable(),
size: z.number().int().min(0).nullable(),
@ -30,6 +32,7 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
cpu_subtype: null,
cpu_type_code: null,
cpu_subtype_code: null,
cpu_subtype_base: null,
capabilities: null,
file_offset: null,
size: null,
@ -54,6 +57,7 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
current.cpusubtype,
current.capabilities,
),
cpu_subtype_base: cpuSubtypeBase(current.cpusubtype),
capabilities: current.capabilities ?? null,
file_offset: integer(current.offset),
size: integer(current.size),
@ -158,10 +162,10 @@ const cpuSubtypeCode = (
};
/** The subtype bits a lipo record names, without its capability byte. */
export const lipoSubtypeBits = (value: string | null): number | null => {
if (value === null) return null;
const cpuSubtypeBase = (value: string | undefined): number | null => {
if (value === undefined) return null;
const subtype = unsigned32(value) ?? CPU_SUBTYPES[value];
return subtype === undefined ? null : subtype & 0x00ffffff;
return subtype === undefined ? null : machoCpuSubtypeBase(subtype);
};
/** lipo names the LIB64 capability and prints other capability bits in hex. */

View File

@ -1,4 +1,5 @@
import { expect, it } from "vitest";
import { CPU } from "../../../../src/artifacts/apple/MachoImage.fixture.js";
import { inspectMachoSchema } from "../../../../src/domain/native/nativeInspection.js";
import { err } from "../../../../src/domain/result.js";
import {
@ -90,18 +91,39 @@ it.each([
selected: "arm64e",
dyldArchitecture: "arm64e.v1",
},
])(
{
architectures: ["arm64v8", "arm64e.v1"],
selected: "arm64e",
dyldArchitecture: "arm64e.v1",
},
{
architectures: ["arm64e.v1", "arm64v8"],
selected: "arm64e",
dyldArchitecture: "arm64e.v1",
},
] as const)(
"selects the $dyldArchitecture slice of a universal arm64e target",
async ({ architectures, selected, dyldArchitecture }) => {
const calls: {
readonly tool: string;
readonly arguments_: readonly string[];
}[] = [];
const cpus = {
x86_64: CPU.x86_64,
arm64e: CPU.arm64e,
arm64v8: { type: CPU.arm64.type, subtype: 1 },
"arm64e.x1": { type: CPU.arm64.type, subtype: 0x8000000c },
"arm64e.v1": { type: CPU.arm64.type, subtype: 0x81000002 },
};
const lipo = architectures
.map(
(architecture, index) =>
`architecture ${architecture}\n cputype 16777228\n cpusubtype 2\n offset ${index * 16384}\n size 8192\n align 2^14 (16384)`,
)
.map((architecture, index) => {
const cpu = cpus[architecture];
const subtype =
architecture === "arm64e.v1"
? "CPU_SUBTYPE_ARM64E\n capabilities PTR_AUTH_VERSION USERSPACE 1"
: String(cpu.subtype);
return `architecture ${architecture}\n cputype ${cpu.type}\n cpusubtype ${subtype}\n offset ${index * 16384}\n size 8192\n align 2^14 (16384)`;
})
.join("\n");
const fixtures = new NativeFixtureRunner({
lipo,
@ -118,6 +140,11 @@ it.each([
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
uuid: "01234567-89AB-CDEF-0123-456789ABCDEF",
selected_architecture: {
name: dyldArchitecture,
container_file_offset:
architectures.findIndex((name) => name === dyldArchitecture) * 16384,
},
architectures: {
items: architectures.map((name) => ({ name })),
},