diff --git a/src/artifacts/apple/MachoImage.fixture.ts b/src/artifacts/apple/MachoImage.fixture.ts index 1f26921a9..d43249d38 100644 --- a/src/artifacts/apple/MachoImage.fixture.ts +++ b/src/artifacts/apple/MachoImage.fixture.ts @@ -11,9 +11,14 @@ export const LC = { DYLD_ENVIRONMENT: 0x27, CODE_SIGNATURE: 0x1d, BUILD_VERSION: 0x32, + VERSION_MIN_MACOSX: 0x24, + VERSION_MIN_IPHONEOS: 0x25, + VERSION_MIN_TVOS: 0x2f, + VERSION_MIN_WATCHOS: 0x30, } as const; export const CPU = { + i386: { type: 7, subtype: 3 }, arm64: { type: 0x0100000c, subtype: 0 }, arm64e: { type: 0x0100000c, subtype: 0x80000002 }, x86_64: { type: 0x01000007, subtype: 3 }, @@ -86,6 +91,16 @@ export const buildVersionCommand = (platform: number): Uint8Array => { return bytes; }; +/** `version_min_command` carrying `LC_VERSION_MIN_*` semantics. */ +export const versionMinCommand = (command: number): Uint8Array => { + const bytes = new Uint8Array(16); + const view = new DataView(bytes.buffer); + view.setUint32(0, command, true); + view.setUint32(4, bytes.length, true); + view.setUint32(8, 0x00090000, true); + return bytes; +}; + /** A 64-bit (or 32-bit) little-endian Mach-O image made of the given commands. */ export const machoImage = (options: { readonly cpu?: { readonly type: number; readonly subtype: number }; diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts index 4250db968..df26940bf 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.test.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts @@ -1,3 +1,7 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; import { @@ -13,16 +17,124 @@ import { machoImage, readerOf, rpathCommand, + versionMinCommand, } from "./MachoImage.fixture.js"; import { MAX_LOAD_COMMAND_BYTES, hasMachoMagic, readMachoImage, } from "./MachoLoadCommandReader.js"; +import { traceDylibResolution } from "./DylibResolutionReader.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; const read = (bytes: Uint8Array) => readMachoImage(readerOf(bytes), bytes.length); +// Legacy `LC_VERSION_MIN_*` commands carry no simulator flag: per dyld +// rules Intel images are simulator, ARM images are device (tvOS simulator +// is x86_64 only). A legacy simulator executable must not fail resolution +// against a modern simulator dylib. +it.each([ + { command: LC.VERSION_MIN_IPHONEOS, cpu: CPU.i386, platform: 7 }, + { command: LC.VERSION_MIN_IPHONEOS, cpu: CPU.x86_64, platform: 7 }, + { command: LC.VERSION_MIN_IPHONEOS, cpu: CPU.arm64, platform: 2 }, + { command: LC.VERSION_MIN_TVOS, cpu: CPU.x86_64, platform: 8 }, + { command: LC.VERSION_MIN_TVOS, cpu: CPU.arm64, platform: 3 }, + { command: LC.VERSION_MIN_WATCHOS, cpu: CPU.i386, platform: 9 }, + { command: LC.VERSION_MIN_WATCHOS, cpu: CPU.arm64, platform: 4 }, + { command: LC.VERSION_MIN_MACOSX, cpu: CPU.x86_64, platform: 1 }, +] as const)( + "classifies legacy command $command on $cpu.type as platform $platform", + async ({ command, cpu, platform }) => { + const facts = await read( + machoImage({ cpu, commands: [versionMinCommand(command)] }), + ); + if (facts.status !== "parsed") throw new Error(facts.status); + expect(facts.slices[0]).toMatchObject({ platform, platforms: [platform] }); + }, +); + +it("uses legacy simulator and device platform facts when resolving dylibs", async () => { + const root = await createTestTempDirectory("rea-macho-legacy-platform-"); + const dylibPath = "Frameworks/libThing.dylib"; + const installName = "@rpath/libThing.dylib"; + const intelExecutable = machoImage({ + cpu: CPU.x86_64, + commands: [ + versionMinCommand(LC.VERSION_MIN_IPHONEOS), + rpathCommand("@executable_path/Frameworks"), + dylibCommand(LC.LOAD_DYLIB, installName), + ], + }); + const armExecutable = machoImage({ + cpu: CPU.arm64, + commands: [ + versionMinCommand(LC.VERSION_MIN_IPHONEOS), + rpathCommand("@executable_path/Frameworks"), + dylibCommand(LC.LOAD_DYLIB, installName), + ], + }); + const simulatorDylib = fatImage([ + { + cpu: CPU.x86_64, + bytes: machoImage({ + cpu: CPU.x86_64, + fileType: FILE_TYPE.dylib, + commands: [ + buildVersionCommand(7), + dylibCommand(LC.ID_DYLIB, installName), + ], + }), + }, + { + cpu: CPU.arm64, + bytes: machoImage({ + cpu: CPU.arm64, + fileType: FILE_TYPE.dylib, + commands: [ + buildVersionCommand(7), + dylibCommand(LC.ID_DYLIB, installName), + ], + }), + }, + ]); + await mkdir(join(root, "Frameworks"), { recursive: true }); + await writeFile(join(root, "Intel"), intelExecutable); + await writeFile(join(root, "Arm"), armExecutable); + await writeFile(join(root, dylibPath), simulatorDylib); + + const trace = async (target: string, architecture: "arm64" | "x86_64") => { + const bytes = target === "Intel" ? intelExecutable : armExecutable; + return traceDylibResolution({ + rootPath: root, + targetPath: join(root, target), + targetSha256: createHash("sha256").update(bytes).digest("hex"), + enumerateRoots: false, + parameters: { roots: [target], architecture }, + }); + }; + const intelTrace = await trace("Intel", "x86_64"); + const armTrace = await trace("Arm", "arm64"); + + expect(intelTrace.edges).toContainEqual( + expect.objectContaining({ + loader: "Intel", + resolution: expect.objectContaining({ + status: "resolved", + image: dylibPath, + }), + }), + ); + expect(armTrace.edges).toContainEqual( + expect.objectContaining({ + loader: "Arm", + candidates: expect.arrayContaining([ + expect.objectContaining({ outcome: "platform-mismatch" }), + ]), + }), + ); +}); + describe("Mach-O load command reader: decodes thin-image load commands", () => { it("decodes dylib loading commands of a thin image", async () => { const image = machoImage({ diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index 6389c5e1b..5a948cb98 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -45,6 +45,47 @@ const LC_VERSION_MIN_TVOS = 0x2f; const LC_VERSION_MIN_WATCHOS = 0x30; const LC_BUILD_VERSION = 0x32; +// `mach-o/loader.h` `PLATFORM_*` numbers observed via Apple linker output. +const PLATFORM_MACOS = 1; +const PLATFORM_IOS = 2; +const PLATFORM_TVOS = 3; +const PLATFORM_WATCHOS = 4; +const PLATFORM_IOSSIMULATOR = 7; +const PLATFORM_TVOSSIMULATOR = 8; +const PLATFORM_WATCHOSSIMULATOR = 9; + +const CPU_TYPE_I386 = 7; +const CPU_TYPE_X86_64 = 0x01000007; + +/** + * Legacy `LC_VERSION_MIN_*` commands predate explicit simulator platform + * IDs. Per Apple's dyld rules the image CPU type distinguishes simulator + * from device: Intel images are simulator, ARM images are device + * (tvOS simulator applies to x86_64 only). `LC_BUILD_VERSION` platform + * numbers are preserved verbatim; unknown future numbers stay unknown + * downstream, never mapped to nearest. + */ +const legacyVersionMinPlatform = ( + command: number, + cpuType: number, +): number | undefined => { + const isIntel = cpuType === CPU_TYPE_I386 || cpuType === CPU_TYPE_X86_64; + switch (command) { + case LC_VERSION_MIN_MACOSX: + return PLATFORM_MACOS; + case LC_VERSION_MIN_IPHONEOS: + return isIntel ? PLATFORM_IOSSIMULATOR : PLATFORM_IOS; + case LC_VERSION_MIN_TVOS: + return cpuType === CPU_TYPE_X86_64 + ? PLATFORM_TVOSSIMULATOR + : PLATFORM_TVOS; + case LC_VERSION_MIN_WATCHOS: + return isIntel ? PLATFORM_WATCHOSSIMULATOR : PLATFORM_WATCHOS; + default: + return undefined; + } +}; + /** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */ const DYLIB_USE_MARKER = 0x1a741800; const DYLIB_USE_WEAK_LINK = 0x1; @@ -377,12 +418,7 @@ const decodeCommand = ( slice.platforms.push(platform); return; } - const legacyPlatform = new Map([ - [LC_VERSION_MIN_MACOSX, 1], - [LC_VERSION_MIN_IPHONEOS, 2], - [LC_VERSION_MIN_TVOS, 3], - [LC_VERSION_MIN_WATCHOS, 4], - ]).get(command); + const legacyPlatform = legacyVersionMinPlatform(command, slice.cpu_type); if (legacyPlatform !== undefined) { if (body.byteLength < 16) throw new MachoFormatIssue(