fix(artifacts): infer legacy Mach-O simulator platforms

This commit is contained in:
morluto 2026-10-10 04:58:25 +08:00
parent 5751f5caac
commit 5aa200285a
3 changed files with 169 additions and 6 deletions

View File

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

View File

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

View File

@ -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<number, number>([
[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(