From 0c49878b9a7f599f82452f728eda7a424527e621 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:03:58 +0800 Subject: [PATCH] fix(macho): preserve subtype labels across dylib consumers --- docs/native-investigation.md | 14 ++- src/artifacts/MachOSliceArchitecture.test.ts | 27 ++++- src/artifacts/MachOSliceArtifactReader.ts | 11 +- .../apple/MachoLoadCommandReader.test.ts | 21 ++++ src/artifacts/apple/MachoLoadCommandReader.ts | 30 +---- src/domain/apple/dyldPaths.ts | 14 ++- src/domain/apple/dylibResolution.test.ts | 103 ++++++++++++++++++ src/domain/apple/dylibResolution.ts | 3 +- src/domain/apple/dylibResolutionFindings.ts | 2 +- src/domain/apple/machoCpuIdentity.ts | 30 +++++ tests/boundary/cli/dylibResolutionCli.test.ts | 30 +++-- 11 files changed, 235 insertions(+), 50 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 125d03ea4c..eb64d16a35 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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 diff --git a/src/artifacts/MachOSliceArchitecture.test.ts b/src/artifacts/MachOSliceArchitecture.test.ts index 50b9a22527..8e111dda95 100644 --- a/src/artifacts/MachOSliceArchitecture.test.ts +++ b/src/artifacts/MachOSliceArchitecture.test.ts @@ -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", + }); +}); diff --git a/src/artifacts/MachOSliceArtifactReader.ts b/src/artifacts/MachOSliceArtifactReader.ts index 9336ee39fa..791e8a5978 100644 --- a/src/artifacts/MachOSliceArtifactReader.ts +++ b/src/artifacts/MachOSliceArtifactReader.ts @@ -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.. 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) ); }; diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts index 04d5b27c1b..f2f3f5fdb3 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.test.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts @@ -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); diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index 0825fcc481..5960ef7b51 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -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" diff --git a/src/domain/apple/dyldPaths.ts b/src/domain/apple/dyldPaths.ts index a9976ae1c2..9209de02c1 100644 --- a/src/domain/apple/dyldPaths.ts +++ b/src/domain/apple/dyldPaths.ts @@ -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))), diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts index 8916dc7db4..c7078b4d69 100644 --- a/src/domain/apple/dylibResolution.test.ts +++ b/src/domain/apple/dylibResolution.test.ts @@ -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>, @@ -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( diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts index 006953c24d..5850a4253e 100644 --- a/src/domain/apple/dylibResolution.ts +++ b/src/domain/apple/dylibResolution.ts @@ -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) { diff --git a/src/domain/apple/dylibResolutionFindings.ts b/src/domain/apple/dylibResolutionFindings.ts index c7f37bb935..8c42260438 100644 --- a/src/domain/apple/dylibResolutionFindings.ts +++ b/src/domain/apple/dylibResolutionFindings.ts @@ -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; diff --git a/src/domain/apple/machoCpuIdentity.ts b/src/domain/apple/machoCpuIdentity.ts index b464453401..cb16f9647f 100644 --- a/src/domain/apple/machoCpuIdentity.ts +++ b/src/domain/apple/machoCpuIdentity.ts @@ -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; +}; diff --git a/tests/boundary/cli/dylibResolutionCli.test.ts b/tests/boundary/cli/dylibResolutionCli.test.ts index 31e6a20eec..7b66aa8adc 100644 --- a/tests/boundary/cli/dylibResolutionCli.test.ts +++ b/tests/boundary/cli/dylibResolutionCli.test.ts @@ -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 }) =>