fix(macho): preserve subtype labels across dylib consumers

This commit is contained in:
morluto committed 2026-10-24 16:08:26 +08:00
1 parent abf7d300b3
commit 0c49878b9a
11 files changed
+235 -50

No files matched your search

+9 -5
View File
@@ -515,11 +515,11 @@ their failure outcome.
and its `container_file_offset` in the input file. A thin Mach-O has a known
offset of zero; an unknown universal-slice offset stays null. The Evidence
subject format describes the input container: `mach-o-universal` for FAT files,
including those with one slice. The normalized result describes the selected
Mach-O image. Architecture
`file_offset` values are container-relative. Entrypoint, segment and section
`file_offset` values retain their source meaning and are relative to the
selected Mach-O slice. Raw load-command fields retain their original values.
including those with one slice. The normalized result also reports the container
in `format`; `selected_architecture` identifies the selected Mach-O image.
Architecture `file_offset` values are container-relative. Entrypoint, segment
and section `file_offset` values retain their source meaning and are relative
to the selected Mach-O slice. Raw load-command fields retain their original values.
A field otool prints more than once, such as each `LC_BUILD_VERSION` tool, is
keyed `name #n` in print order. Each `LC_LINKER_OPTION` string is keyed
`string #n` and checked against the declared `count`. Evidence locations map the LC_MAIN entrypoint and nonempty file-backed segment
@@ -556,6 +556,10 @@ complete observation.
its outcome. Paths outside the analyzed root, including shared-cache system
libraries, stay undetermined. See
[Apple application analysis](apple-application-analysis.md#dylib-load-resolution).
ARM64 slice labels distinguish `arm64v8`, `arm64e`, and `arm64e.x1` while
retaining raw CPU subtype and capability bits. The `arm64` and `arm64e`
request filters select their respective families; a shared family label
does not establish dependency compatibility across different subtypes.
Executable-root discovery preserves traversal uncertainty, including
undecodable filenames and skipped symlinks, as partial coverage. Known
executable roots remain available; unexamined names and symlink targets
+25 -2
View File
@@ -17,11 +17,12 @@ const armSlices = async (
readonly size?: number;
readonly alignment?: number;
} = {},
cpuType = 12,
) => {
const root = await createTestTempDirectory("rea-arm-slice-identity-");
const path = join(root, "fat");
const cpu = { type: 12, subtype };
const bytes = machoImage({ cpu, wide: false });
const cpu = { type: cpuType, subtype };
const bytes = machoImage({ cpu, wide: cpuType !== 12 });
await writeFile(path, fatImage([{ cpu, bytes }]));
const reader = new MachOSliceArtifactReader(
path,
@@ -106,3 +107,25 @@ it.each([
reason: "integrity",
});
});
it.each([
["arm64v8", 1],
["arm64", 1],
["arm64e.x1", 12],
["arm64e", 12],
["arm64e.v1", 0x81000002],
] as const)(
"reconciles ARM64 source name %s with subtype %i",
async (name, subtype) => {
const entries = await armSlices(name, subtype, {}, 0x0100000c);
expect(entries).toHaveLength(1);
expect(entries[0]?.path).toBe(`slices/${name}`);
expect(entries[0]?.limitations).toEqual([]);
},
);
it("does not accept an ARM64 subtype label from a different slice", async () => {
await expect(armSlices("arm64v8", 0, {}, 0x0100000c)).rejects.toMatchObject({
reason: "integrity",
});
});
+7 -4
View File
@@ -13,7 +13,10 @@ import {
type LipoArchitecture,
} from "../native/parsers/lipo.js";
import type { MachoSlice } from "../domain/apple/dylibResolution.js";
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import {
machoArchitectureFamily,
machoCpuSubtypeBase,
} from "../domain/apple/machoCpuIdentity.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { readMachoImage, type ReadAt } from "./apple/MachoLoadCommandReader.js";
import {
@@ -630,10 +633,10 @@ const lipoNameMatches = (reported: string, observed: MachoSlice): boolean => {
ARM_SUBTYPES[reported] ===
machoCpuSubtypeBase(observed.fat_cpu_subtype ?? observed.cpu_subtype)
);
// Newer lipo names pointer-auth ABI variants arm64e.<variant>. Their
// physical type/subtype, range and alignment are still compared separately.
if (reported === machoArchitectureFamily(observed.architecture)) return true;
// Retain lipo's capability-variant spelling; physical identity is checked separately.
return (
observed.architecture === "arm64e" &&
machoArchitectureFamily(observed.architecture) === "arm64e" &&
/^arm64e\.[A-Za-z0-9_]+$/u.test(reported)
);
};
@@ -1566,6 +1566,27 @@ describe("Mach-O load command reader: reads and validates universal binary slice
}
});
it.each([
[1, "arm64v8"],
[2, "arm64e"],
[12, "arm64e.x1"],
[0x8000000c, "arm64e.x1"],
[0x81000002, "arm64e"],
] as const)(
"names ARM64 subtype %i as %s without losing source bits",
async (subtype, architecture) => {
const facts = await read(
machoImage({ cpu: { type: CPU.arm64.type, subtype } }),
);
expect(facts).toMatchObject({
status: "parsed",
slices: [
expect.objectContaining({ architecture, cpu_subtype: subtype }),
],
});
},
);
it("retains physical slice identity and declared build platform", async () => {
const thin = machoImage({ commands: [buildVersionCommand(7)] });
const parsedThin = await read(thin);
+5 -25
View File
@@ -8,7 +8,10 @@ import type {
MachoImageFacts,
MachoSlice,
} from "../../domain/apple/dylibResolution.js";
import { machoCpuSubtypeBase } from "../../domain/apple/machoCpuIdentity.js";
import {
machoArchitectureName,
machoCpuSubtypeBase,
} from "../../domain/apple/machoCpuIdentity.js";
import {
legacyMachoPlatform,
type MachoVersionMinPlatform,
@@ -401,7 +404,7 @@ const decodeCommands = (
readonly maxVmOffset: bigint;
} => {
const slice: MachoSlice = {
architecture: architectureName(header.cpuType, header.cpuSubtype),
architecture: machoArchitectureName(header.cpuType, header.cpuSubtype),
slice_offset: header.sliceOffset,
slice_size: header.sliceSize,
cpu_type: header.cpuType,
@@ -1009,29 +1012,6 @@ const readExact = async (
return bytes;
};
const CPU_ARCH_ABI64 = 0x01000000;
const CPU_ARCH_ABI64_32 = 0x02000000;
const CPU_TYPE_X86 = 7;
const CPU_TYPE_ARM = 12;
const CPU_SUBTYPE_ARM64E = 2;
const CPU_SUBTYPE_ARM64E_X1 = 12;
const CPU_SUBTYPE_X86_64_H = 8;
const architectureName = (cpuType: number, cpuSubtype: number): string => {
const subtype = machoCpuSubtypeBase(cpuSubtype);
if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64))
// Both pointer-authentication subtypes belong to the arm64e family.
return subtype === CPU_SUBTYPE_ARM64E || subtype === CPU_SUBTYPE_ARM64E_X1
? "arm64e"
: "arm64";
if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64_32)) return "arm64_32";
if (cpuType === (CPU_TYPE_X86 | CPU_ARCH_ABI64))
return subtype === CPU_SUBTYPE_X86_64_H ? "x86_64h" : "x86_64";
if (cpuType === CPU_TYPE_X86) return "i386";
if (cpuType === CPU_TYPE_ARM) return "arm";
return `cpu-${cpuType.toString(16)}`;
};
const fileTypeName = (fileType: number): MachoSlice["file_type"] =>
fileType === 2
? "execute"
+9 -5
View File
@@ -3,7 +3,10 @@ import type {
DylibTreeView,
MachoSlice,
} from "./dylibResolution.js";
import { machoCpuSubtypeBase } from "./machoCpuIdentity.js";
import {
machoArchitectureFamily,
machoCpuSubtypeBase,
} from "./machoCpuIdentity.js";
const MAX_SYMLINK_HOPS = 32;
@@ -155,13 +158,14 @@ export const compatibleSlice = (
}
return { kind: "missing" };
}
for (const architecture of COMPATIBLE_ARCHITECTURES[process.architecture] ?? [
process.architecture,
const processFamily = machoArchitectureFamily(process.architecture);
for (const architecture of COMPATIBLE_ARCHITECTURES[processFamily] ?? [
processFamily,
]) {
const slice = slices.find(
(item) =>
item.architecture === architecture &&
(process.architecture !== "arm64e" ||
machoArchitectureFamily(item.architecture) === architecture &&
(processFamily !== "arm64e" ||
(item.cpu_type === process.cpu_type &&
machoCpuSubtypeBase(item.cpu_subtype) ===
machoCpuSubtypeBase(process.cpu_subtype))),
+103
View File
@@ -91,6 +91,44 @@ it("uses hidden ARM64 slices as selectable images while preserving their marker"
);
});
it.each([
{ architecture: "arm64e", expected: ["arm64e", "arm64e.x1"] },
{ architecture: "arm64", expected: ["arm64", "arm64v8"] },
{ architecture: "x86_64", expected: [] },
{
architecture: undefined,
expected: ["arm64", "arm64v8", "arm64e", "arm64e.x1"],
},
] as const)(
"preserves exact root labels with family filter $architecture",
async ({ architecture, expected }) => {
const facts = parsed(
...(
[
[0, "arm64"],
[1, "arm64v8"],
[2, "arm64e"],
[12, "arm64e.x1"],
] as const
).map(([subtype, name]) =>
slice({
architecture: name,
cpu_subtype: subtype,
file_type: "execute",
}),
),
);
const trace = await traceDylibLoading(memoryView({ [MAIN]: facts }), {
roots: [MAIN],
...(architecture === undefined ? {} : { architecture }),
});
expect(trace.roots.map((root) => root.architecture)).toEqual(expected);
expect(trace.coverage.roots_without_architecture).toEqual(
expected.length === 0 ? [MAIN] : [],
);
},
);
/** In-memory tree: files are images (or "data"), symlinks map to targets. */
const memoryView = (
files: Readonly<Record<string, MachoImageFacts | "data">>,
@@ -897,6 +935,71 @@ describe("loader-relative dylib resolution", () => {
});
});
it.each([
{
rootName: "arm64v8",
rootSubtype: 1,
dylibName: "arm64",
dylibSubtype: 0,
outcome: "resolved",
},
{
rootName: "arm64",
rootSubtype: 0,
dylibName: "arm64v8",
dylibSubtype: 1,
outcome: "resolved",
},
{
rootName: "arm64e.x1",
rootSubtype: 12,
dylibName: "arm64e",
dylibSubtype: 2,
outcome: "architecture-missing",
},
{
rootName: "arm64e",
rootSubtype: 2,
dylibName: "arm64e.x1",
dylibSubtype: 12,
outcome: "architecture-missing",
},
{
rootName: "arm64e.x1",
rootSubtype: 12,
dylibName: "arm64e.x1",
dylibSubtype: 12,
outcome: "resolved",
},
] as const)(
"preserves compatibility from $rootName to $dylibName",
async ({ rootName, rootSubtype, dylibName, dylibSubtype, outcome }) => {
const library = "Contents/MacOS/libfamily.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
architecture: rootName,
cpu_subtype: rootSubtype,
dependencies: [dependency("@executable_path/libfamily.dylib")],
}),
[library]: parsed(
slice({ architecture: dylibName, cpu_subtype: dylibSubtype }),
),
}),
{ roots: [MAIN] },
);
expect(trace.edges).toHaveLength(1);
expect(trace.edges[0]).toMatchObject({
architecture: rootName,
candidates: [expect.objectContaining({ outcome })],
resolution:
outcome === "resolved"
? { status: "resolved", image: library }
: { status: "unresolved", image: null },
});
},
);
describe("dyld slice compatibility and coverage", () => {
it("keeps an unmodeled ARM32 subtype uncertain when matching slices exist", async () => {
const trace = await traceDylibLoading(
+2 -1
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { machoArchitectureFamily } from "./machoCpuIdentity.js";
import { AnalysisCancelledError } from "../analysisErrorCore.js";
import {
@@ -751,7 +752,7 @@ export const traceDylibLoading = async (
const slices = machoImageSlices(facts).filter(
({ architecture }) =>
request.architecture === undefined ||
architecture === request.architecture,
machoArchitectureFamily(architecture) === request.architecture,
);
if (slices.length === 0) withoutArchitecture.push(root);
for (const slice of slices) {
+1 -1
View File
@@ -43,7 +43,7 @@ export const deriveFindings = (
explanation: `The modeled dyld search checks ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A compatible Mach-O placed at an earlier modeled path could take precedence over that fallback, subject to unmodeled search inputs and code-signing library validation; library validation is not evaluated here (see inspect_signature).${edge.loader_conditional ? " This loader loads only conditionally; if it never loads, this search never occurs." : ""}${edge.resolution.status === "conditional" ? " Unmodeled environment overrides or earlier unknown candidates may take precedence; placing an image at this rpath does not establish which image dyld will load." : ""}`,
});
});
// Slices can share an architecture name (arm64e and arm64e.x1), so each
// Capability-bit variants can share an architecture name, so each
// finding uses the slice its root was traced from, not a name lookup.
for (const { image, slice } of roots) {
const { architecture } = slice;
+30
View File
@@ -4,3 +4,33 @@ const CPU_SUBTYPE_CAPABILITY_MASK = 0xff000000;
/** Return the CPU subtype identity shared across capability-flag variations. */
export const machoCpuSubtypeBase = (cpuSubtype: number): number =>
cpuSubtype & ~CPU_SUBTYPE_CAPABILITY_MASK;
/**
* Display known byte-header identities without inferring pointer-auth ABI names.
* Subtype 12: llvm-project b8007a8e4020b8bca2b12e941660e10bf5bf6716,
* MachO.h / MachOObjectFile.cpp. Capability bits remain independent source facts.
*/
export const machoArchitectureName = (
cpuType: number,
cpuSubtype: number,
): string => {
const subtype = machoCpuSubtypeBase(cpuSubtype);
if (cpuType === 0x0100000c) {
if (subtype === 12) return "arm64e.x1";
if (subtype === 2) return "arm64e";
if (subtype === 1) return "arm64v8";
return "arm64";
}
if (cpuType === 0x0200000c) return "arm64_32";
if (cpuType === 0x01000007) return subtype === 8 ? "x86_64h" : "x86_64";
if (cpuType === 7) return "i386";
if (cpuType === 12) return "arm";
return `cpu-${cpuType.toString(16)}`;
};
/** Known exact display aliases retain the existing family-based request semantics. */
export const machoArchitectureFamily = (architecture: string): string => {
if (architecture === "arm64e.x1") return "arm64e";
if (architecture === "arm64v8") return "arm64";
return architecture;
};
+23 -7
View File
@@ -643,21 +643,33 @@ cliTest(
);
cliTest(
"reports each same-named slice's own embedded dyld environment",
"preserves exact architecture labels and each same-named slice's dyld environment",
async ({ cli }) => {
// arm64e and arm64e.x1 slices both report the architecture name arm64e.
// Capability variants can share arm64e's display name, while .x1 stays exact.
// A plain x86_64 slice keeps the bundle openable on x64 hosts.
for (const [settings, expected] of [
[[null, "DYLD_PRINT_RPATHS=1"], ["DYLD_PRINT_RPATHS=1"]],
[[null, null, "DYLD_PRINT_RPATHS=1"], ["DYLD_PRINT_RPATHS=1"]],
[
["DYLD_PRINT_LIBRARIES=1", "DYLD_PRINT_RPATHS=1"],
["DYLD_PRINT_LIBRARIES=1", "DYLD_PRINT_RPATHS=1"],
[
"DYLD_PRINT_LIBRARIES=1",
"DYLD_PRINT_RPATHS=1",
"DYLD_PRINT_BINDINGS=1",
],
[
"DYLD_PRINT_LIBRARIES=1",
"DYLD_PRINT_RPATHS=1",
"DYLD_PRINT_BINDINGS=1",
],
],
] as const) {
const app = await fixtureApp();
await writeFiles(app, {
"Contents/MacOS/App": fatImage([
...[CPU.arm64e, CPU.arm64eX1].map((cpu, index) => {
...[
CPU.arm64e,
CPU.arm64eX1,
{ type: CPU.arm64.type, subtype: 0x81000002 },
].map((cpu, index) => {
const setting = settings[index];
return {
cpu,
@@ -689,7 +701,11 @@ cliTest(
const trace = dylibResolutionResultSchema.parse(
parseEvidence(result.json).normalized_result,
);
expect(trace.roots).toHaveLength(2);
expect(trace.roots.map(({ architecture }) => architecture)).toEqual([
"arm64e",
"arm64e.x1",
"arm64e",
]);
const environments = trace.findings
.filter(({ kind }) => kind === "dyld-environment-present")
.map(({ explanation }) =>