fix(apple): validate physical slices and dylib platform compatibility

This commit is contained in:
morluto
2026-10-09 00:20:49 +08:00
parent 16f4609d68
commit 15f92f2988
15 changed files with 1019 additions and 95 deletions
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { MachOSliceArtifactReader } from "./MachOSliceArtifactReader.js";
describe("Mach-O slice reader with system lipo", () => {
it.skipIf(process.platform !== "darwin")(
"checks real /bin/ls lipo CPU symbols and ptrauth capabilities against the FAT table",
async () => {
const reader = new MachOSliceArtifactReader("/bin/ls");
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
expect(entries.map(({ path }) => path)).toEqual([
"slices/x86_64",
"slices/arm64e",
]);
expect(
entries.map(({ byteOffset, declaredSize }) => [
byteOffset,
declaredSize,
]),
).toEqual([
[16384, 48112],
[65536, 89088],
]);
},
);
});
+126 -14
View File
@@ -1,5 +1,5 @@
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { open as openFile, stat } from "node:fs/promises";
import { Readable } from "node:stream";
import type { ArtifactCommand } from "../domain/artifactGraph.js";
@@ -7,7 +7,12 @@ import {
XcrunCommandRunner,
type NativeCommandRunner,
} from "../native/CommandRunner.js";
import { parseLipoArchitectures } from "../native/parsers/lipo.js";
import {
parseLipoArchitectures,
type LipoArchitecture,
} from "../native/parsers/lipo.js";
import type { MachoSlice } from "../domain/apple/dylibResolution.js";
import { readMachoImage, type ReadAt } from "./apple/MachoLoadCommandReader.js";
import {
ArtifactReaderFailure,
type ArtifactEntry,
@@ -46,19 +51,56 @@ export class MachOSliceArtifactReader implements ArtifactReader {
effects: ["read"],
};
const fileSize = (await stat(this.path)).size;
for (const architecture of parseLipoArchitectures(captured.value.stdout)) {
if (architecture.file_offset === null || architecture.size === null)
const architectures = parseLipoArchitectures(captured.value.stdout);
const structural = await readStructuralSlices(this.path, fileSize);
if (structural.status === "malformed")
throw new ArtifactReaderFailure(
"integrity",
`Mach-O structural slice index is malformed: ${structural.reason}`,
);
if (structural.status === "unsupported")
throw new ArtifactReaderFailure(
"format",
`Mach-O structural slice index is unsupported: ${structural.reason}`,
);
const slices = structural.status === "parsed" ? structural.slices : null;
const matchingSlices = new Map<LipoArchitecture, MachoSlice>();
if (slices !== null) {
if (slices.length !== architectures.length)
throw new ArtifactReaderFailure(
"integrity",
"lipo architecture count disagrees with the Mach-O FAT table",
);
const unmatched = [...slices];
for (const reported of architectures) {
const match = unmatched.findIndex((observed) =>
lipoMatchesSlice(reported, observed, slices.length === 1),
);
if (match < 0)
throw new ArtifactReaderFailure(
"integrity",
`lipo slice metadata disagrees with the Mach-O FAT table: ${reported.name} (cputype=${reported.cpu_type ?? "unknown"}, cpusubtype=${reported.cpu_subtype ?? "unknown"}, offset=${reported.file_offset ?? "unknown"}, size=${reported.size ?? "unknown"}, alignment=${reported.alignment ?? "unknown"})`,
);
const [observed] = unmatched.splice(match, 1);
if (observed !== undefined) matchingSlices.set(reported, observed);
}
}
for (const architecture of architectures) {
const structuralSlice = matchingSlices.get(architecture);
const offset = architecture.file_offset ?? structuralSlice?.slice_offset;
const sliceSize = architecture.size ?? structuralSlice?.slice_size;
if (offset === undefined || sliceSize === undefined)
throw new ArtifactReaderFailure(
"integrity",
"lipo omitted a universal slice byte range",
);
if (
!Number.isSafeInteger(architecture.file_offset) ||
!Number.isSafeInteger(architecture.size) ||
architecture.file_offset < 0 ||
architecture.size <= 0 ||
!Number.isSafeInteger(architecture.file_offset + architecture.size) ||
architecture.file_offset + architecture.size > fileSize
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(sliceSize) ||
offset < 0 ||
sliceSize <= 0 ||
!Number.isSafeInteger(offset + sliceSize) ||
offset + sliceSize > fileSize
)
throw new ArtifactReaderFailure(
"integrity",
@@ -67,15 +109,38 @@ export class MachOSliceArtifactReader implements ArtifactReader {
yield {
path: `slices/${architecture.name}`,
kind: "slice",
declaredSize: architecture.size,
declaredSize: sliceSize,
compressedSize: null,
executable: true,
encrypted: false,
byteOffset: architecture.file_offset,
byteOffset: offset,
declaredSha256: null,
unpacked: false,
limitations: [],
adapterKey: `${String(architecture.file_offset)}:${String(architecture.size)}`,
limitations: [
...(structural.status === "not-mach-o"
? [
"Structural Mach-O facts were unavailable; slice range is based on lipo output.",
]
: []),
...(architecture.file_offset === null || architecture.size === null
? [
"Slice range was derived from structural Mach-O facts because lipo omitted it.",
]
: []),
...(architecture.cpu_type !== null &&
architecture.cpu_type_code === null
? [
`lipo CPU type is retained as unnormalized source text: ${architecture.cpu_type}`,
]
: []),
...(architecture.cpu_subtype !== null &&
architecture.cpu_subtype_code === null
? [
`lipo CPU subtype is retained as unnormalized source text: ${architecture.cpu_subtype}${architecture.capabilities === null ? "" : `; capabilities ${architecture.capabilities}`}`,
]
: []),
],
adapterKey: `${String(offset)}:${String(sliceSize)}`,
};
}
}
@@ -157,6 +222,53 @@ export class MachOSliceArtifactReader implements ArtifactReader {
}
}
/** Compare lipo's independent ranges with structurally parsed slice identity. */
const readStructuralSlices = async (
path: string,
size: number,
): Promise<Awaited<ReturnType<typeof readMachoImage>>> => {
const file = await openFile(path, "r");
try {
const readAt: ReadAt = async (offset, length) => {
const bytes = Buffer.alloc(length);
const { bytesRead } = await file.read(bytes, 0, length, offset);
return bytes.subarray(0, bytesRead);
};
const facts = await readMachoImage(readAt, size);
return facts;
} finally {
await file.close();
}
};
const lipoMatchesSlice = (
reported: LipoArchitecture,
observed: MachoSlice,
structurallyUnique: boolean,
): boolean => {
const observedCpuType = observed.fat_cpu_type ?? observed.cpu_type;
const observedCpuSubtype = observed.fat_cpu_subtype ?? observed.cpu_subtype;
const hasPhysicalIdentity =
reported.file_offset !== null ||
reported.size !== null ||
reported.cpu_type_code !== null ||
reported.cpu_subtype_code !== null;
return (
(hasPhysicalIdentity || structurallyUnique) &&
reported.name === observed.architecture &&
(reported.file_offset === null ||
reported.file_offset === observed.slice_offset) &&
(reported.size === null || reported.size === observed.slice_size) &&
(reported.cpu_type_code === null ||
reported.cpu_type_code === observedCpuType) &&
(reported.cpu_subtype_code === null ||
reported.cpu_subtype_code === observedCpuSubtype) &&
(reported.alignment === null ||
observed.fat_alignment_exponent === null ||
reported.alignment === 2 ** observed.fat_alignment_exponent)
);
};
const parseSliceKeyInteger = (value: string | undefined): number | null => {
if (value === undefined || !/^\d+$/u.test(value)) return null;
const parsed = Number(value);
+11
View File
@@ -10,6 +10,7 @@ export const LC = {
RPATH: 0x8000001c,
DYLD_ENVIRONMENT: 0x27,
CODE_SIGNATURE: 0x1d,
BUILD_VERSION: 0x32,
} as const;
export const CPU = {
@@ -75,6 +76,16 @@ export const codeSignatureCommand = (): Uint8Array => {
return bytes;
};
/** `build_version_command` carrying the producer's raw platform number. */
export const buildVersionCommand = (platform: number): Uint8Array => {
const bytes = new Uint8Array(24);
const view = new DataView(bytes.buffer);
view.setUint32(0, LC.BUILD_VERSION, true);
view.setUint32(4, bytes.length, true);
view.setUint32(8, platform, 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 };
@@ -5,6 +5,7 @@ import {
FILE_TYPE,
LC,
codeSignatureCommand,
buildVersionCommand,
dyldEnvironmentCommand,
dylibCommand,
dylibUseCommand,
@@ -22,7 +23,7 @@ import {
const read = (bytes: Uint8Array) =>
readMachoImage(readerOf(bytes), bytes.length);
describe("Mach-O load command reader", () => {
describe("Mach-O load command reader: decodes thin-image load commands", () => {
it("decodes dylib loading commands of a thin image", async () => {
const image = machoImage({
fileType: FILE_TYPE.dylib,
@@ -124,7 +125,9 @@ describe("Mach-O load command reader", () => {
},
]);
});
});
describe("Mach-O load command reader: reads and validates universal binary slices", () => {
it("reads every slice of FAT and FAT_64 containers in either byte order", async () => {
for (const [wide, littleEndian] of [
[false, false],
@@ -163,6 +166,63 @@ describe("Mach-O load command reader", () => {
]);
}
});
it("retains physical slice identity and declared build platform", async () => {
const thin = machoImage({ commands: [buildVersionCommand(7)] });
const parsedThin = await read(thin);
expect(
parsedThin.status === "parsed" && parsedThin.slices[0],
).toMatchObject({
slice_offset: 0,
slice_size: thin.length,
cpu_type: CPU.arm64.type,
cpu_subtype: CPU.arm64.subtype,
fat_cpu_type: null,
fat_cpu_subtype: null,
platform: 7,
});
const fat = fatImage([{ cpu: CPU.arm64, bytes: thin }]);
const parsedFat = await read(fat);
expect(parsedFat.status === "parsed" && parsedFat.slices[0]).toMatchObject({
slice_offset: 4096,
slice_size: thin.length,
fat_cpu_type: CPU.arm64.type,
fat_cpu_subtype: CPU.arm64.subtype,
});
});
it("rejects FAT table/header identity mismatches and unaligned ranges", async () => {
const original = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
for (const [fieldOffset, value] of [
[8, CPU.x86_64.type],
[12, 99],
] as const) {
const mismatch = original.slice();
new DataView(mismatch.buffer).setUint32(fieldOffset, value, false);
expect(await read(mismatch)).toMatchObject({
status: "malformed",
reason: expect.stringContaining("disagrees"),
});
}
const unaligned = original.slice();
new DataView(unaligned.buffer).setUint32(16, 4097, false);
expect(await read(unaligned)).toMatchObject({
status: "malformed",
reason: expect.stringContaining("not aligned"),
});
});
it("preserves unknown CPU table facts when the embedded slice is Mach-O", async () => {
const cpu = { type: 0x7fffffff, subtype: 11 };
const facts = await read(fatImage([{ cpu, bytes: machoImage({ cpu }) }]));
expect(facts.status === "parsed" && facts.slices[0]).toMatchObject({
architecture: "cpu-7fffffff",
cpu_type: cpu.type,
cpu_subtype: cpu.subtype,
fat_cpu_type: cpu.type,
fat_cpu_subtype: cpu.subtype,
});
});
});
describe("Mach-O load command reader inputs", () => {
@@ -314,7 +374,7 @@ describe("Mach-O load command reader failures", () => {
const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
expect(await read(fat.subarray(0, 4100))).toMatchObject({
status: "malformed",
reason: "FAT architecture 0 extends beyond the file",
reason: "FAT architecture 0 slice range extends beyond the file",
});
});
});
+130 -41
View File
@@ -3,6 +3,11 @@ import type {
MachoImageFacts,
MachoSlice,
} from "../../domain/apple/dylibResolution.js";
import {
readFatSliceDeclarations,
validateFatSlice,
type FatSliceDeclaration,
} from "../../domain/apple/machoContainer.js";
/** Read `length` bytes at `offset`; shorter results mean end of file. */
export type ReadAt = (offset: number, length: number) => Promise<Uint8Array>;
@@ -34,6 +39,11 @@ const LC_LOAD_UPWARD_DYLIB = 0x80000023;
const LC_RPATH = 0x8000001c;
const LC_DYLD_ENVIRONMENT = 0x27;
const LC_CODE_SIGNATURE = 0x1d;
const LC_VERSION_MIN_MACOSX = 0x24;
const LC_VERSION_MIN_IPHONEOS = 0x25;
const LC_VERSION_MIN_TVOS = 0x2f;
const LC_VERSION_MIN_WATCHOS = 0x30;
const LC_BUILD_VERSION = 0x32;
/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
const DYLIB_USE_MARKER = 0x1a741800;
@@ -109,22 +119,55 @@ export const readMachoImage = async (
};
if (count > MAX_FAT_ARCHITECTURES || 8 + count * (wide ? 32 : 20) > size)
return { status: "not-mach-o" };
const table = viewOf(
await readExact(readAt, 8, count * (wide ? 32 : 20), "FAT table"),
const tableBytes = await readExact(
readAt,
8,
count * (wide ? 32 : 20),
"FAT table",
);
const declarations = readFatSliceDeclarations(tableBytes, {
count,
wide,
littleEndian,
});
if (declarations.status !== "parsed")
throw new MachoFormatIssue("malformed", declarations.reason);
// A Java class file's version fields read as a FAT count; its "table" names no CPU.
const cpuTypes = Array.from({ length: count }, (_, index) =>
table.getUint32(index * (wide ? 32 : 20), littleEndian),
);
if (!cpuTypes.some((type) => KNOWN_CPU_TYPES.includes(type)))
return { status: "not-mach-o" };
if (
!declarations.slices.some(({ cpuType }) =>
KNOWN_CPU_TYPES.includes(cpuType),
)
) {
let hasMachOSliceHeader = false;
for (const declaration of declarations.slices) {
if (
validateFatSlice(
declaration,
size,
8 + count * (wide ? 32 : 20),
) !== null
)
continue;
const bytes = await readAt(declaration.offset, 4);
if (bytes.byteLength < 4) continue;
const sliceMagic = viewOf(bytes).getUint32(0, false);
if (
[MH_MAGIC, MH_MAGIC_64, MH_CIGAM, MH_CIGAM_64].includes(sliceMagic)
) {
hasMachOSliceHeader = true;
break;
}
}
if (!hasMachOSliceHeader) return { status: "not-mach-o" };
}
return {
status: "parsed",
slices: await readFatSlices(readAt, size, {
wide,
littleEndian,
count,
}),
slices: await readFatSlices(
readAt,
size,
8 + count * (wide ? 32 : 20),
declarations.slices,
),
};
}
if (magic === MH_CIGAM || magic === MH_CIGAM_64)
@@ -145,32 +188,30 @@ export const readMachoImage = async (
const readFatSlices = async (
readAt: ReadAt,
size: number,
table: {
readonly wide: boolean;
readonly littleEndian: boolean;
readonly count: number;
},
tableEnd: number,
declarations: readonly FatSliceDeclaration[],
): Promise<MachoSlice[]> => {
const { wide, littleEndian, count } = table;
const entrySize = wide ? 32 : 20;
const view = viewOf(
await readExact(readAt, 8, count * entrySize, "FAT table"),
);
const slices: MachoSlice[] = [];
for (let index = 0; index < count; index++) {
const base = index * entrySize;
const offset = wide
? safeNumber(view.getBigUint64(base + 8, littleEndian))
: view.getUint32(base + 8, littleEndian);
const length = wide
? safeNumber(view.getBigUint64(base + 16, littleEndian))
: view.getUint32(base + 12, littleEndian);
if (offset + length > size)
for (const declaration of declarations) {
const { index, offset, size: length, cpuType, cpuSubtype } = declaration;
const rangeIssue = validateFatSlice(declaration, size, tableEnd);
if (rangeIssue !== null)
throw new MachoFormatIssue(
"malformed",
`FAT architecture ${index} extends beyond the file`,
rangeIssue.includes("unsupported") ? "unsupported" : "malformed",
rangeIssue,
);
slices.push(await readSlice(readAt, offset, length));
const slice = await readSlice(readAt, offset, length, {
fatCpuType: cpuType,
fatCpuSubtype: cpuSubtype,
alignmentExponent: declaration.alignmentExponent,
});
const identityIssue = validateFatSlice(declaration, size, tableEnd, {
cpuType: slice.cpu_type,
cpuSubtype: slice.cpu_subtype,
});
if (identityIssue !== null)
throw new MachoFormatIssue("malformed", identityIssue);
slices.push(slice);
}
return slices;
};
@@ -179,6 +220,11 @@ const readSlice = async (
readAt: ReadAt,
base: number,
length: number,
fat: {
readonly fatCpuType: number;
readonly fatCpuSubtype: number;
readonly alignmentExponent: number;
} | null = null,
): Promise<MachoSlice> => {
const header = viewOf(await readExact(readAt, base, 28, "Mach-O header"));
const magic = header.getUint32(0, true);
@@ -203,13 +249,22 @@ const readSlice = async (
commandBytes,
"load commands",
);
return decodeCommands(commands, {
const slice = decodeCommands(commands, {
wide,
cpuType: header.getUint32(4, true),
cpuSubtype: header.getUint32(8, true),
fileType: header.getUint32(12, true),
commandCount,
sliceOffset: base,
sliceSize: length,
fatCpuType: fat?.fatCpuType ?? null,
fatCpuSubtype: fat?.fatCpuSubtype ?? null,
fatAlignmentExponent: fat?.alignmentExponent ?? null,
});
const distinctPlatforms = [...new Set(slice.platforms)];
slice.platform =
distinctPlatforms.length === 1 ? (distinctPlatforms[0] ?? null) : null;
return slice;
}
throw new MachoFormatIssue(
magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64)
@@ -229,6 +284,11 @@ const decodeCommands = (
readonly cpuSubtype: number;
readonly fileType: number;
readonly commandCount: number;
readonly sliceOffset: number;
readonly sliceSize: number;
readonly fatCpuType: number | null;
readonly fatCpuSubtype: number | null;
readonly fatAlignmentExponent: number | null;
},
): MachoSlice => {
// loader.h: load commands are 8-byte aligned in 64-bit images, 4 in 32-bit.
@@ -236,7 +296,17 @@ const decodeCommands = (
const view = viewOf(bytes);
const slice: MachoSlice = {
architecture: architectureName(header.cpuType, header.cpuSubtype),
slice_offset: header.sliceOffset,
slice_size: header.sliceSize,
cpu_type: header.cpuType,
cpu_subtype: header.cpuSubtype,
fat_cpu_type: header.fatCpuType,
fat_cpu_subtype: header.fatCpuSubtype,
fat_alignment_exponent: header.fatAlignmentExponent,
file_type: fileTypeName(header.fileType),
file_type_code: header.fileType,
platform: null,
platforms: [],
install_name: null,
dependencies: [],
rpaths: [],
@@ -292,6 +362,31 @@ const decodeCommand = (
slice.code_signature_present = true;
return;
}
if (command === LC_BUILD_VERSION) {
if (body.byteLength < 24)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short for LC_BUILD_VERSION`,
);
const platform = viewOf(body).getUint32(8, true);
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);
if (legacyPlatform !== undefined) {
if (body.byteLength < 16)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short for a version-min command`,
);
slice.platforms.push(legacyPlatform);
return;
}
const name = DEPENDENCY_COMMANDS.get(command);
if (name !== undefined)
slice.dependencies.push(decodeDependency(name, body, index));
@@ -416,11 +511,5 @@ const swap = (value: number): number =>
(value >>> 24)) >>>
0;
const safeNumber = (value: bigint): number => {
if (value > BigInt(Number.MAX_SAFE_INTEGER))
throw new MachoFormatIssue("malformed", "FAT offset exceeds safe range");
return Number(value);
};
const viewOf = (bytes: Uint8Array): DataView =>
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+115 -2
View File
@@ -11,7 +11,23 @@ import {
const slice = (overrides: Partial<MachoSlice> = {}): MachoSlice => ({
architecture: "arm64",
file_type: "dylib",
slice_offset: 0,
slice_size: 32,
cpu_type: 0x0100000c,
cpu_subtype: 0,
fat_cpu_type: null,
fat_cpu_subtype: null,
fat_alignment_exponent: null,
file_type: overrides.file_type ?? "dylib",
file_type_code:
overrides.file_type_code ??
{ execute: 2, dylib: 6, bundle: 8, other: 1 }[
overrides.file_type ?? "dylib"
],
platform: overrides.platform ?? 1,
platforms:
overrides.platforms ??
(overrides.platform === null ? [] : [overrides.platform ?? 1]),
install_name: null,
dependencies: [],
rpaths: [],
@@ -234,7 +250,7 @@ describe("dyld path expansion", () => {
});
});
describe("dyld resolution outcomes", () => {
describe("dyld resolution outcomes: deriving path and weak-load outcomes", () => {
it("keeps paths outside the root undetermined and in-root fallbacks conditional", async () => {
const trace = await traceDylibLoading(
memoryView({
@@ -308,7 +324,9 @@ describe("dyld resolution outcomes", () => {
"Contents/MacOS/../Overrides/libfound.dylib",
);
});
});
describe("dyld resolution outcomes: checking candidate format and compatibility", () => {
it("reports non-Mach-O, malformed and wrong-architecture candidates", async () => {
const trace = await traceDylibLoading(
memoryView({
@@ -345,6 +363,101 @@ describe("dyld resolution outcomes", () => {
"Contents/MacOS/c/lib.dylib",
]);
});
it("skips non-loadable and wrong-platform rpath candidates", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
rpaths: [
"@executable_path/a",
"@executable_path/b",
"@executable_path/c",
],
dependencies: [dependency("@rpath/lib.dylib")],
}),
"Contents/MacOS/a/lib.dylib": parsed(
slice({ file_type: "other", file_type_code: 1 }),
),
"Contents/MacOS/b/lib.dylib": parsed(slice({ platform: 7 })),
"Contents/MacOS/c/lib.dylib": parsed(slice()),
}),
{ roots: [MAIN] },
);
const edge = edgeFor(trace, MAIN, "@rpath/lib.dylib");
expect(edge.candidates.map(({ outcome }) => outcome)).toEqual([
"not-loadable",
"platform-mismatch",
"resolved",
]);
expect(edge.resolution).toEqual({
status: "resolved",
image: "Contents/MacOS/c/lib.dylib",
});
});
it("preserves unknown platform compatibility before a later known candidate", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
rpaths: ["@executable_path/a", "@executable_path/b"],
dependencies: [dependency("@rpath/lib.dylib")],
}),
"Contents/MacOS/a/lib.dylib": parsed(slice({ platform: null })),
"Contents/MacOS/b/lib.dylib": parsed(slice()),
}),
{ roots: [MAIN] },
);
const edge = edgeFor(trace, MAIN, "@rpath/lib.dylib");
expect(edge.candidates.map(({ outcome }) => outcome)).toEqual([
"undetermined",
"resolved",
]);
expect(edge.resolution.status).toBe("conditional");
});
it("applies dyld Catalyst and zippered platform compatibility", async () => {
const catalystProcess = await traceDylibLoading(
memoryView({
[MAIN]: executable({
platform: 6,
platforms: [6],
rpaths: ["@executable_path/catalyst"],
dependencies: [dependency("@rpath/lib.dylib")],
}),
"Contents/MacOS/catalyst/lib.dylib": parsed(
slice({ platform: 1, platforms: [1] }),
),
}),
{ roots: [MAIN] },
);
expect(
edgeFor(catalystProcess, MAIN, "@rpath/lib.dylib").resolution,
).toEqual({
status: "resolved",
image: "Contents/MacOS/catalyst/lib.dylib",
});
const zipperedImage = await traceDylibLoading(
memoryView({
[MAIN]: executable({
platform: 1,
platforms: [1],
rpaths: ["@executable_path/zippered"],
dependencies: [dependency("@rpath/lib.dylib")],
}),
"Contents/MacOS/zippered/lib.dylib": parsed(
slice({ platform: null, platforms: [1, 6] }),
),
}),
{ roots: [MAIN] },
);
expect(
edgeFor(zipperedImage, MAIN, "@rpath/lib.dylib").resolution,
).toMatchObject({
status: "resolved",
image: "Contents/MacOS/zippered/lib.dylib",
});
});
});
describe("dyld load order", () => {
+108 -6
View File
@@ -48,7 +48,20 @@ const dependencySchema = z.strictObject({
/** Observed dylib-loading facts of one Mach-O slice. */
export const machoSliceSchema = z.strictObject({
architecture: z.string().min(1),
/** Physical identity observed in the thin header and, for FAT, its table. */
slice_offset: z.number().int().nonnegative(),
slice_size: z.number().int().positive(),
cpu_type: z.number().int().nonnegative(),
cpu_subtype: z.number().int().nonnegative(),
fat_cpu_type: z.number().int().nonnegative().nullable(),
fat_cpu_subtype: z.number().int().nonnegative().nullable(),
fat_alignment_exponent: z.number().int().nonnegative().nullable(),
file_type: z.enum(["execute", "dylib", "bundle", "other"]),
file_type_code: z.number().int().nonnegative(),
/** Raw LC_BUILD_VERSION platform number; null means no such command was observed. */
platform: z.number().int().nonnegative().nullable(),
/** Every distinct build platform declared by LC_BUILD_VERSION/version-min commands. */
platforms: z.array(z.number().int().nonnegative()),
install_name: z.string().nullable(),
dependencies: z.array(dependencySchema),
rpaths: z.array(z.string()),
@@ -97,6 +110,8 @@ const candidateSchema = z.strictObject({
"malformed",
"unsupported",
"architecture-missing",
"not-loadable",
"platform-mismatch",
"outside-target",
"escapes-target",
"undetermined",
@@ -192,6 +207,7 @@ interface ProcessContext {
readonly view: DylibTreeView;
readonly root: string;
readonly architecture: string;
readonly platforms: readonly number[];
readonly executable: string | null;
readonly loaded: Map<string, LoadedImage>;
readonly byInstallName: Map<string, string>;
@@ -269,15 +285,90 @@ const evaluateCandidate = async (
if (lookup.kind !== "file")
return { ...base, outcome: "absent", resolved_path: null };
const facts = await imageFacts(context, lookup.path);
const outcome: Candidate["outcome"] =
facts.status !== "parsed"
? facts.status
: compatibleSlice(facts.slices, context.architecture) === undefined
? "architecture-missing"
: "resolved";
let outcome: Candidate["outcome"];
if (facts.status !== "parsed") outcome = facts.status;
else {
const slice = compatibleSlice(facts.slices, context.architecture);
if (slice === undefined) outcome = "architecture-missing";
else if (![2, 6, 7, 8].includes(slice.file_type_code))
outcome = "not-loadable";
else {
const platformResult = platformLoadability(
context.platforms,
slice.platforms,
slice.file_type_code,
context.architecture,
);
outcome =
platformResult === "compatible"
? "resolved"
: platformResult === "incompatible"
? "platform-mismatch"
: "undetermined";
}
}
return { ...base, outcome, resolved_path: lookup.path };
};
type PlatformLoadability = "compatible" | "incompatible" | "unknown";
/** Apply dyld's documented cross-platform load cases and retain uncertain host cases. */
const platformLoadability = (
processPlatforms: readonly number[],
imagePlatforms: readonly number[],
fileType: number,
architecture: string,
): PlatformLoadability => {
if (processPlatforms.length === 0 || imagePlatforms.length === 0)
return "unknown";
const outcomes = processPlatforms.map((processPlatform) =>
imageLoadabilityForPlatform(
processPlatform,
imagePlatforms,
fileType,
architecture,
),
);
if (outcomes.every((outcome) => outcome === "compatible"))
return "compatible";
if (outcomes.every((outcome) => outcome === "incompatible"))
return "incompatible";
return "unknown";
};
const imageLoadabilityForPlatform = (
processPlatform: number,
imagePlatforms: readonly number[],
fileType: number,
architecture: string,
): PlatformLoadability => {
if (imagePlatforms.includes(processPlatform)) return "compatible";
// These are explicit dyld loadableIntoProcess cross-platform cases.
if (processPlatform === 6 && imagePlatforms.includes(1)) return "compatible"; // macOS dylibs in Mac Catalyst processes
if (processPlatform === 2 && imagePlatforms.includes(6)) return "compatible"; // Catalyst dylibs in iOS processes
if (processPlatform === 2 && imagePlatforms.includes(11)) return "compatible"; // visionOS dylibs in iOS processes
if (processPlatform === 7 && imagePlatforms.includes(12)) return "compatible"; // visionOS simulator dylibs in iOS simulator processes
if (processPlatform === 1 && fileType === 2 && imagePlatforms.includes(6))
return "compatible"; // Catalyst main executables run on macOS
// dyld's remaining exceptions depend on host architecture or a special path.
if (
(processPlatform === 2 &&
imagePlatforms.includes(1) &&
architecture.startsWith("arm64")) ||
([7, 8, 9].includes(processPlatform) && imagePlatforms.includes(1))
)
return "unknown";
if (isKnownPlatform(processPlatform) && imagePlatforms.every(isKnownPlatform))
return "incompatible";
return "unknown";
};
const isKnownPlatform = (platform: number): boolean =>
platform >= 1 && platform <= 12;
const imageFacts = async (
context: ProcessContext,
path: string,
@@ -455,6 +546,7 @@ export const traceDylibLoading = async (
view,
root,
architecture: slice.architecture,
platforms: slice.platforms,
executable: slice.file_type === "execute" ? root : null,
loaded: new Map(),
byInstallName: new Map(),
@@ -503,7 +595,17 @@ const withoutDependencies = (
slice: MachoSlice,
): Omit<MachoSlice, "dependencies"> => ({
architecture: slice.architecture,
slice_offset: slice.slice_offset,
slice_size: slice.slice_size,
cpu_type: slice.cpu_type,
cpu_subtype: slice.cpu_subtype,
fat_cpu_type: slice.fat_cpu_type,
fat_cpu_subtype: slice.fat_cpu_subtype,
fat_alignment_exponent: slice.fat_alignment_exponent,
file_type: slice.file_type,
file_type_code: slice.file_type_code,
platform: slice.platform,
platforms: slice.platforms,
install_name: slice.install_name,
rpaths: slice.rpaths,
dyld_environment: slice.dyld_environment,
+95
View File
@@ -0,0 +1,95 @@
/** Physical facts for one entry in a Mach-O FAT architecture table. */
export interface FatSliceDeclaration {
readonly index: number;
readonly offset: number;
readonly size: number;
readonly alignmentExponent: number;
readonly cpuType: number;
readonly cpuSubtype: number;
}
/** Decode FAT table entries once so artifact and native readers share identity facts. */
export const readFatSliceDeclarations = (
tableBytes: Uint8Array,
options: {
readonly count: number;
readonly wide: boolean;
readonly littleEndian: boolean;
},
):
| {
readonly status: "parsed";
readonly slices: readonly FatSliceDeclaration[];
}
| { readonly status: "malformed"; readonly reason: string } => {
const { count, wide, littleEndian } = options;
const entrySize = wide ? 32 : 20;
if (tableBytes.byteLength < count * entrySize)
return { status: "malformed", reason: "FAT table is truncated" };
const view = new DataView(
tableBytes.buffer,
tableBytes.byteOffset,
tableBytes.byteLength,
);
const slices: FatSliceDeclaration[] = [];
for (let index = 0; index < count; index++) {
const base = index * entrySize;
const offset = wide
? safeNumber(view.getBigUint64(base + 8, littleEndian))
: view.getUint32(base + 8, littleEndian);
const size = wide
? safeNumber(view.getBigUint64(base + 16, littleEndian))
: view.getUint32(base + 12, littleEndian);
if (offset === null || size === null)
return {
status: "malformed",
reason: "FAT offset or size exceeds the safe range",
};
slices.push({
index,
offset,
size,
alignmentExponent: view.getUint32(base + (wide ? 24 : 16), littleEndian),
cpuType: view.getUint32(base, littleEndian),
cpuSubtype: view.getUint32(base + 4, littleEndian),
});
}
return { status: "parsed", slices };
};
/** Validate byte-range and table/header identity facts shared by Mach-O readers. */
export const validateFatSlice = (
declaration: FatSliceDeclaration,
containerSize: number,
tableEnd: number,
embedded?: { readonly cpuType: number; readonly cpuSubtype: number },
): string | null => {
const { index, offset, size, alignmentExponent, cpuType, cpuSubtype } =
declaration;
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(size) ||
size <= 0 ||
offset < tableEnd
)
return `FAT architecture ${index} has an invalid slice range`;
if (offset > containerSize || size > containerSize - offset)
return `FAT architecture ${index} slice range extends beyond the file`;
if (
!Number.isInteger(alignmentExponent) ||
alignmentExponent < 0 ||
alignmentExponent > 52
)
return `FAT architecture ${index} alignment exponent ${alignmentExponent} is unsupported`;
if (offset % 2 ** alignmentExponent !== 0)
return `FAT architecture ${index} offset is not aligned to 2^${alignmentExponent}`;
if (
embedded !== undefined &&
(embedded.cpuType !== cpuType || embedded.cpuSubtype !== cpuSubtype)
)
return `FAT architecture ${index} CPU type/subtype disagrees with its Mach-O header`;
return null;
};
const safeNumber = (value: bigint): number | null =>
value > BigInt(Number.MAX_SAFE_INTEGER) ? null : Number(value);
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import {
CPU,
fatImage,
machoImage,
} from "../artifacts/apple/MachoImage.fixture.js";
import { parseMachoLayout } from "./AppleMachoSelection.js";
describe("native Mach-O slice selection", () => {
it("accepts a FAT slice whose table identity and embedded header agree", () => {
const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
expect(parseMachoLayout(Buffer.from(fat), "arm64").segments).toEqual([]);
});
it("rejects FAT subtype disagreement and unaligned slice offsets", () => {
const original = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
const mismatched = original.slice();
new DataView(mismatched.buffer).setUint32(12, 2, false);
expect(() => parseMachoLayout(Buffer.from(mismatched), "arm64")).toThrow(
/disagrees/u,
);
const unaligned = original.slice();
new DataView(unaligned.buffer).setUint32(16, 4097, false);
expect(() => parseMachoLayout(Buffer.from(unaligned), "arm64")).toThrow(
/not aligned/u,
);
});
});
+42 -16
View File
@@ -1,4 +1,8 @@
import type { FixupCommands } from "./AppleMachoFixups.js";
import {
readFatSliceDeclarations,
validateFatSlice,
} from "../domain/apple/machoContainer.js";
/** One mapped Mach-O segment with file-backed VA range. */
export interface Segment {
@@ -19,6 +23,16 @@ export interface Section {
export interface MachoSlice {
slice: number;
sliceEnd: number;
/** FAT table identity, absent for a thin image. */
fatDeclaration?: {
offset: number;
size: number;
cpuType: number;
cpuSubtype: number;
alignmentExponent: number;
index: number;
tableEnd: number;
};
}
/** Parsed little-endian 64-bit Mach-O layout for the selected architecture slice. */
@@ -66,9 +80,6 @@ export const selectMachoSlice = (
const readUInt32 = littleEndian
? (offset: number) => bytes.readUInt32LE(offset)
: (offset: number) => bytes.readUInt32BE(offset);
const readUInt64 = littleEndian
? (offset: number) => bytes.readBigUInt64LE(offset)
: (offset: number) => bytes.readBigUInt64BE(offset);
const byteOrder = littleEndian ? "little-endian" : "big-endian";
const malformed = (reason: string) =>
new RangeError(`${reason} (FAT header byte order: ${byteOrder})`);
@@ -80,21 +91,27 @@ export const selectMachoSlice = (
if (count > 128 || headerEnd > bytes.length)
throw malformed("Malformed FAT architecture table");
const cpu = architecture === "arm64" ? 0x0100000c : 0x01000007;
const declarations = readFatSliceDeclarations(bytes.subarray(8, headerEnd), {
count,
wide: fat64,
littleEndian,
});
if (declarations.status !== "parsed") throw malformed(declarations.reason);
let selected: MachoSlice | undefined;
for (let index = 0; index < count; index++) {
const offset = 8 + index * stride;
if (readUInt32(offset) !== cpu) continue;
for (const declaration of declarations.slices) {
const issue = validateFatSlice(declaration, bytes.length, headerEnd);
if (issue !== null) throw malformed(issue);
if (declaration.cpuType !== cpu) continue;
if (selected !== undefined)
throw invalid("Ambiguous FAT architecture slice");
const start = fat64
? readUInt64(offset + 8)
: BigInt(readUInt32(offset + 8));
const size = fat64
? readUInt64(offset + 16)
: BigInt(readUInt32(offset + 12));
if (start < BigInt(headerEnd) || start + size > BigInt(bytes.length))
throw malformed("FAT slice exceeds file");
selected = { slice: Number(start), sliceEnd: Number(start + size) };
selected = {
slice: declaration.offset,
sliceEnd: declaration.offset + declaration.size,
fatDeclaration: {
...declaration,
tableEnd: headerEnd,
},
};
}
if (selected === undefined)
throw invalid("Requested FAT architecture is absent");
@@ -107,13 +124,22 @@ export const parseMachoLayout = (
architecture: string,
): MachoLayout => {
if (bytes.length < 4) throw new RangeError("Truncated Mach-O header");
const { slice, sliceEnd } = selectMachoSlice(bytes, architecture);
const selection = selectMachoSlice(bytes, architecture);
const { slice, sliceEnd } = selection;
if (slice === 0 && sliceEnd === bytes.length && bytes.length < 32)
throw new RangeError("Truncated Mach-O header");
if (slice + 32 > sliceEnd || bytes.readUInt32LE(slice) !== 0xfeedfacf)
throw new TypeError(
"Only little-endian 64-bit Mach-O metadata is supported",
);
const fat = selection.fatDeclaration;
if (fat !== undefined) {
const issue = validateFatSlice(fat, bytes.length, fat.tableEnd, {
cpuType: bytes.readUInt32LE(slice + 4),
cpuSubtype: bytes.readUInt32LE(slice + 8),
});
if (issue !== null) throw new TypeError(issue);
}
const commands = bytes.readUInt32LE(slice + 16);
const commandEnd = slice + 32 + bytes.readUInt32LE(slice + 20);
if (commands > 4096 || commandEnd > sliceEnd)
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { parseLipoArchitectures } from "./lipo.js";
describe("lipo architecture facts", () => {
it("normalizes symbolic CPU fields and arm64e capability bits", () => {
const architectures = parseLipoArchitectures(`Fat header in: /bin/ls
fat_magic 0xcafebabe
nfat_arch 2
architecture x86_64
cputype CPU_TYPE_X86_64
cpusubtype CPU_SUBTYPE_X86_64_ALL
capabilities 0x0
offset 16384
size 48112
align 2^14 (16384)
architecture arm64e
cputype CPU_TYPE_ARM64
cpusubtype CPU_SUBTYPE_ARM64E
capabilities PTR_AUTH_VERSION USERSPACE 0
offset 65536
size 89088
align 2^14 (16384)
`);
expect(architectures).toEqual([
expect.objectContaining({
name: "x86_64",
cpu_type: "CPU_TYPE_X86_64",
cpu_type_code: 0x01000007,
cpu_subtype: "CPU_SUBTYPE_X86_64_ALL",
cpu_subtype_code: 3,
capabilities: "0x0",
file_offset: 16384,
size: 48112,
alignment: 16384,
}),
expect.objectContaining({
name: "arm64e",
cpu_type: "CPU_TYPE_ARM64",
cpu_type_code: 0x0100000c,
cpu_subtype: "CPU_SUBTYPE_ARM64E",
cpu_subtype_code: 0x80000002,
capabilities: "PTR_AUTH_VERSION USERSPACE 0",
file_offset: 65536,
size: 89088,
alignment: 16384,
}),
]);
});
it("keeps unknown symbolic CPU values as source text without a code", () => {
expect(
parseLipoArchitectures(
"architecture future\n cputype CPU_TYPE_FUTURE\n cpusubtype CPU_SUBTYPE_FUTURE\n offset 4096\n size 64\n align 2^12\n",
),
).toMatchObject([
{
cpu_type: "CPU_TYPE_FUTURE",
cpu_type_code: null,
cpu_subtype: "CPU_SUBTYPE_FUTURE",
cpu_subtype_code: null,
},
]);
});
});
+82 -1
View File
@@ -4,6 +4,9 @@ const architectureSchema = z.object({
name: z.string().min(1),
cpu_type: z.string().nullable(),
cpu_subtype: z.string().nullable(),
cpu_type_code: z.number().int().nonnegative().nullable(),
cpu_subtype_code: z.number().int().nonnegative().nullable(),
capabilities: z.string().nullable(),
file_offset: z.number().int().min(0).nullable(),
size: z.number().int().min(0).nullable(),
alignment: z.number().int().min(0).nullable(),
@@ -25,6 +28,9 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
name: thinArchitecture,
cpu_type: null,
cpu_subtype: null,
cpu_type_code: null,
cpu_subtype_code: null,
capabilities: null,
file_offset: null,
size: null,
alignment: null,
@@ -43,6 +49,12 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
: name,
cpu_type: current.cputype ?? null,
cpu_subtype: current.cpusubtype ?? null,
cpu_type_code: cpuTypeCode(current.cputype),
cpu_subtype_code: cpuSubtypeCode(
current.cpusubtype,
current.capabilities,
),
capabilities: current.capabilities ?? null,
file_offset: integer(current.offset),
size: integer(current.size),
alignment: alignment(current.align),
@@ -62,7 +74,10 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
current = { architecture: line.slice("architecture ".length).trim() };
continue;
}
const match = /^(cputype|cpusubtype|offset|size|align)\s+(.+)$/u.exec(line);
const match =
/^(cputype|cpusubtype|capabilities|offset|size|align)\s+(.+)$/u.exec(
line,
);
if (match?.[1] !== undefined && match[2] !== undefined) {
current ??= {};
current[match[1]] = match[2].trim();
@@ -80,6 +95,72 @@ const integer = (value: string | undefined): number | null => {
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
};
const CPU_TYPES: Readonly<Record<string, number>> = {
CPU_TYPE_X86: 7,
CPU_TYPE_X86_64: 0x01000007,
CPU_TYPE_ARM: 12,
CPU_TYPE_ARM64: 0x0100000c,
CPU_TYPE_ARM64_32: 0x0200000c,
CPU_TYPE_POWERPC: 18,
CPU_TYPE_POWERPC64: 0x01000012,
};
const CPU_SUBTYPES: Readonly<Record<string, number>> = {
CPU_SUBTYPE_X86_ALL: 3,
CPU_SUBTYPE_X86_64_ALL: 3,
CPU_SUBTYPE_X86_64_H: 8,
CPU_SUBTYPE_ARM64_ALL: 0,
CPU_SUBTYPE_ARM64_V8: 1,
CPU_SUBTYPE_ARM64E: 2,
CPU_SUBTYPE_ARM64_32_ALL: 0,
CPU_SUBTYPE_ARM64_32_V8: 1,
CPU_SUBTYPE_ARM_V7: 9,
CPU_SUBTYPE_ARM_V7S: 11,
CPU_SUBTYPE_ARM_V7K: 12,
CPU_SUBTYPE_POWERPC_ALL: 0,
CPU_SUBTYPE_POWERPC_970: 100,
};
/** Normalize known lipo names while retaining the producer's original text. */
const cpuTypeCode = (value: string | undefined): number | null => {
if (value === undefined) return null;
const numeric = unsigned32(value);
return numeric ?? CPU_TYPES[value] ?? null;
};
/** Normalize the subtype and its separately printed ptrauth capability bits. */
const cpuSubtypeCode = (
value: string | undefined,
capabilities: string | undefined,
): number | null => {
if (value === undefined) return null;
const numeric = unsigned32(value);
if (numeric !== null) return numeric;
const subtype = CPU_SUBTYPES[value];
if (subtype === undefined) return null;
if (value !== "CPU_SUBTYPE_ARM64E") return subtype;
const version = /PTR_AUTH_VERSION\s+USERSPACE\s+(\d+)/u.exec(
capabilities ?? "",
)?.[1];
if (version === undefined) return null;
const parsedVersion = Number(version);
if (
!Number.isInteger(parsedVersion) ||
parsedVersion < 0 ||
parsedVersion > 15
)
return null;
return (0x80000000 | (parsedVersion << 24) | subtype) >>> 0;
};
const unsigned32 = (value: string): number | null => {
if (!/^(?:\d+|0x[\da-f]+)$/iu.test(value)) return null;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0xffffffff
? parsed >>> 0
: null;
};
const alignment = (value: string | undefined): number | null => {
if (value === undefined) return null;
const exponent = /^2\^(\d+)(?: \((\d+)\))?$/u.exec(value);
+15 -3
View File
@@ -6,6 +6,7 @@ import { describe, expect } from "vitest";
import {
FILE_TYPE,
LC,
buildVersionCommand,
dylibCommand,
machoImage,
rpathCommand,
@@ -36,6 +37,7 @@ const fixtureApp = async (): Promise<string> => {
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
"Contents/MacOS/App": machoImage({
commands: [
buildVersionCommand(1),
rpathCommand("@executable_path/../Frameworks"),
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libgone.dylib"),
@@ -43,10 +45,16 @@ const fixtureApp = async (): Promise<string> => {
}),
"Contents/Frameworks/libcore.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib")],
commands: [
buildVersionCommand(1),
dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib"),
],
}),
"Contents/Helpers/tool": machoImage({
commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib")],
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib"),
],
}),
});
return app;
@@ -153,11 +161,15 @@ describe("trace-dylib-resolution CLI", () => {
await writeFiles(directory, {
tool: machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/lib/libhelper.dylib"),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/../escape.dylib"),
],
}),
"lib/libhelper.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
"lib/libhelper.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
"unrelated.txt": "not traversed",
});
const result = await cli.run({
+14 -3
View File
@@ -9,6 +9,7 @@ import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import {
FILE_TYPE,
LC,
buildVersionCommand,
dylibCommand,
machoImage,
rpathCommand,
@@ -55,6 +56,7 @@ it("traces dylib resolution for an opened app bundle and rejects a changed targe
executable,
machoImage({
commands: [
buildVersionCommand(1),
rpathCommand("@executable_path/../Frameworks"),
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
],
@@ -62,7 +64,10 @@ it("traces dylib resolution for an opened app bundle and rejects a changed targe
);
await writeFile(
join(app, "Contents/Frameworks/libcore.dylib"),
machoImage({ fileType: FILE_TYPE.dylib }),
machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
);
await withClient(async (client) => {
const opened = await client.callTool({
@@ -129,12 +134,18 @@ it("traces a standalone Mach-O file whose name ends in .app", async () => {
await writeFile(
join(directory, "Tool.app"),
machoImage({
commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libcore.dylib")],
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libcore.dylib"),
],
}),
);
await writeFile(
join(directory, "libcore.dylib"),
machoImage({ fileType: FILE_TYPE.dylib }),
machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
}),
);
await withClient(async (client) => {
const opened = await client.callTool({
@@ -6,6 +6,11 @@ import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { MachOSliceArtifactReader } from "../../../../src/artifacts/MachOSliceArtifactReader.js";
import {
CPU,
fatImage,
machoImage,
} from "../../../../src/artifacts/apple/MachoImage.fixture.js";
import { ok } from "../../../../src/domain/result.js";
import type { NativeCommandRunner } from "../../../../src/native/CommandRunner.js";
@@ -46,7 +51,13 @@ describe("artifact Mach-O slices", () => {
it("uses lipo metadata to read universal slice ranges", async () => {
const root = await createTestTempDirectory("rea-slices-");
const binary = join(root, "fat");
await writeFile(binary, Buffer.from("0123456789abcdef"));
const x86 = machoImage({ cpu: CPU.x86_64 });
const arm = machoImage({ cpu: CPU.arm64 });
const image = fatImage([
{ cpu: CPU.x86_64, bytes: x86 },
{ cpu: CPU.arm64, bytes: arm },
]);
await writeFile(binary, image);
const runner: NativeCommandRunner = {
run: () =>
Promise.resolve(
@@ -57,8 +68,7 @@ describe("artifact Mach-O slices", () => {
toolVersion: null,
versionReason: "fixture",
arguments: ["-detailed_info", binary],
stdout:
"architecture x86_64\n cputype 16777223\n cpusubtype 3\n offset 0\n size 8\n align 2^2\narchitecture arm64\n cputype 16777228\n cpusubtype 0\n offset 8\n size 8\n align 2^2\n",
stdout: `architecture x86_64\n cputype 16777223\n cpusubtype 3\n offset 4096\n size ${x86.length}\n align 2^12\narchitecture arm64\n cputype 16777228\n cpusubtype 0\n offset 8192\n size ${arm.length}\n align 2^12\n`,
stderr: "",
stdoutBytes: 1,
stderrBytes: 0,
@@ -76,17 +86,17 @@ describe("artifact Mach-O slices", () => {
if (secondEntry === undefined) return;
expect(secondEntry).toMatchObject({
path: "slices/arm64",
byteOffset: 8,
declaredSize: 8,
byteOffset: 8192,
declaredSize: arm.length,
});
for (const adapterKey of ["0x8:8", "1e3:8", " 8 :8"])
for (const adapterKey of ["0x2000:8", "1e3:8", " 8192 :8"])
await expect(
reader.open({ ...secondEntry, adapterKey }),
).rejects.toMatchObject({ reason: "integrity" });
const chunks: Buffer[] = [];
const stream = await reader.open(secondEntry);
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks).toString()).toBe("89abcdef");
expect(Buffer.concat(chunks)).toEqual(Buffer.from(arm));
expect(reader.provenance()).toEqual([
expect.objectContaining({ tool: "lipo", effects: ["read"] }),
]);
@@ -95,4 +105,84 @@ describe("artifact Mach-O slices", () => {
Reflect.set(provenance[0], "tool", "forged");
expect(reader.provenance()[0]?.tool).toBe("lipo");
});
it.each([
["CPU type", "cputype 16777223"],
["CPU subtype", "cpusubtype 2"],
["alignment", "align 2^11 (2048)"],
["offset", "offset 4097"],
])(
"rejects lipo %s that disagrees with physical Mach-O facts",
async (_label, field) => {
const root = await createTestTempDirectory("rea-slices-mismatch-");
const binary = join(root, "fat");
const thin = machoImage({ cpu: CPU.arm64 });
await writeFile(binary, fatImage([{ cpu: CPU.arm64, bytes: thin }]));
const details = [
"architecture arm64",
" cputype 16777228",
" cpusubtype 0",
" offset 4096",
` size ${thin.length}`,
" align 2^12 (4096)",
];
const index = field.startsWith("cputype")
? 1
: field.startsWith("cpusubtype")
? 2
: field.startsWith("align")
? 5
: 3;
details[index] = ` ${field}`;
const reader = new MachOSliceArtifactReader(
binary,
lipoRunner(`${details.join("\n")}\n`),
);
await expect(async () => {
for await (const _entry of reader.entries()) {
// The structural comparison runs before the reader yields a slice.
}
}).rejects.toMatchObject({ reason: "integrity" });
},
);
it("does not trust lipo after a malformed structural Mach-O parse", async () => {
const root = await createTestTempDirectory("rea-slices-malformed-");
const binary = join(root, "fat");
const thin = machoImage({ cpu: CPU.arm64 });
new DataView(thin.buffer).setUint32(16, 1, true);
const image = fatImage([{ cpu: CPU.arm64, bytes: thin }]);
await writeFile(binary, image);
const reader = new MachOSliceArtifactReader(
binary,
lipoRunner(
`architecture arm64\n cputype 16777228\n cpusubtype 0\n offset 4096\n size ${thin.length}\n align 2^12 (4096)\n`,
),
);
await expect(async () => {
for await (const _entry of reader.entries()) {
// Malformed structural facts must fail before any slice is yielded.
}
}).rejects.toThrow(/structural slice index is malformed/u);
});
});
const lipoRunner = (stdout: string): NativeCommandRunner => ({
run: () =>
Promise.resolve(
ok({
tool: "lipo",
executable: "/usr/bin/lipo",
executableSha256: "1".repeat(64),
toolVersion: null,
versionReason: "fixture",
arguments: ["-detailed_info", "fixture"],
stdout,
stderr: "",
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: 0,
exitCode: 0,
signal: null,
}),
),
});