diff --git a/src/artifacts/MachOSliceArtifactReader.test.ts b/src/artifacts/MachOSliceArtifactReader.test.ts new file mode 100644 index 0000000000..8db4ab16fc --- /dev/null +++ b/src/artifacts/MachOSliceArtifactReader.test.ts @@ -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], + ]); + }, + ); +}); diff --git a/src/artifacts/MachOSliceArtifactReader.ts b/src/artifacts/MachOSliceArtifactReader.ts index 9c14b4024e..a242feaf40 100644 --- a/src/artifacts/MachOSliceArtifactReader.ts +++ b/src/artifacts/MachOSliceArtifactReader.ts @@ -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(); + 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>> => { + 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); diff --git a/src/artifacts/apple/MachoImage.fixture.ts b/src/artifacts/apple/MachoImage.fixture.ts index 634ab4ecf6..1f26921a9f 100644 --- a/src/artifacts/apple/MachoImage.fixture.ts +++ b/src/artifacts/apple/MachoImage.fixture.ts @@ -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 }; diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts index 892312ea0d..4a76caf22c 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.test.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts @@ -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", }); }); }); diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index b78ce7fbc6..8b7593a653 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -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; @@ -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 => { - 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 => { 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([ + [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); diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts index bfd0a25b58..4cf8de1257 100644 --- a/src/domain/apple/dylibResolution.test.ts +++ b/src/domain/apple/dylibResolution.test.ts @@ -11,7 +11,23 @@ import { const slice = (overrides: Partial = {}): 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", () => { diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts index 241a50be84..9566700db0 100644 --- a/src/domain/apple/dylibResolution.ts +++ b/src/domain/apple/dylibResolution.ts @@ -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; readonly byInstallName: Map; @@ -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 => ({ 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, diff --git a/src/domain/apple/machoContainer.ts b/src/domain/apple/machoContainer.ts new file mode 100644 index 0000000000..55a809be65 --- /dev/null +++ b/src/domain/apple/machoContainer.ts @@ -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); diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts new file mode 100644 index 0000000000..d5efe9afd5 --- /dev/null +++ b/src/native/AppleMachoSelection.test.ts @@ -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, + ); + }); +}); diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 95e3feaf1b..f813a04bec 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -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) diff --git a/src/native/parsers/lipo.test.ts b/src/native/parsers/lipo.test.ts new file mode 100644 index 0000000000..4de28398f6 --- /dev/null +++ b/src/native/parsers/lipo.test.ts @@ -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, + }, + ]); + }); +}); diff --git a/src/native/parsers/lipo.ts b/src/native/parsers/lipo.ts index 6d55c12ac4..4fb13cda7c 100644 --- a/src/native/parsers/lipo.ts +++ b/src/native/parsers/lipo.ts @@ -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> = { + 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> = { + 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); diff --git a/tests/boundary/cli/dylibResolutionCli.test.ts b/tests/boundary/cli/dylibResolutionCli.test.ts index 1911d85517..a5f29f1b8b 100644 --- a/tests/boundary/cli/dylibResolutionCli.test.ts +++ b/tests/boundary/cli/dylibResolutionCli.test.ts @@ -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 => { "CFBundleExecutableApp", "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 => { }), "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({ diff --git a/tests/boundary/mcp/dylibResolutionMcp.test.ts b/tests/boundary/mcp/dylibResolutionMcp.test.ts index 179912a182..d2416a1b0b 100644 --- a/tests/boundary/mcp/dylibResolutionMcp.test.ts +++ b/tests/boundary/mcp/dylibResolutionMcp.test.ts @@ -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({ diff --git a/tests/boundary/providers/native/artifactProvider.machOSlices.test.ts b/tests/boundary/providers/native/artifactProvider.machOSlices.test.ts index 501c46d9b6..2da3bb2690 100644 --- a/tests/boundary/providers/native/artifactProvider.machOSlices.test.ts +++ b/tests/boundary/providers/native/artifactProvider.machOSlices.test.ts @@ -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, + }), + ), });