diff --git a/src/artifacts/apple/machoContainer.test.ts b/src/artifacts/apple/machoContainer.test.ts index 235ba4d6e7..9d7e42129f 100644 --- a/src/artifacts/apple/machoContainer.test.ts +++ b/src/artifacts/apple/machoContainer.test.ts @@ -12,7 +12,9 @@ import { javaClassFile } from "../../../tests/fixtures/javaClassFile.js"; import { parseExecutableHeader } from "../../domain/binaryTarget.js"; import { classifyFatContainerIdentity, + findOverlappingFatSlices, readFatContainerHeader, + readFatSliceDeclarations, readMachoHeaderPrefixFacts, } from "../../domain/apple/machoContainer.js"; @@ -29,6 +31,94 @@ const classify = (bytes: Uint8Array) => { }); }; +describe("FAT declaration facts", () => { + it.each([ + { wide: false, littleEndian: false }, + { wide: false, littleEndian: true }, + { wide: true, littleEndian: false }, + { wide: true, littleEndian: true }, + ])( + "retains raw fields and reserved-field absence for wide=$wide littleEndian=$littleEndian", + async ({ wide, littleEndian }) => { + const arm64e = machoImage({ cpu: CPU.arm64e }); + const x86 = machoImage({ cpu: CPU.x86_64 }); + const bytes = fatImage( + [ + { cpu: CPU.arm64e, bytes: arm64e }, + { cpu: CPU.x86_64, bytes: x86 }, + ], + wide, + littleEndian, + ); + if (wide) + new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).setUint32(8 + 32 + 28, 0x89abcdef, littleEndian); + const declarations = readFatSliceDeclarations( + bytes.subarray(8, 8 + 2 * (wide ? 32 : 20)), + { count: 2, wide, littleEndian }, + ); + expect(declarations).toEqual({ + status: "parsed", + slices: [ + { + index: 0, + offset: 4096, + size: arm64e.byteLength, + alignmentExponent: 12, + cpuType: CPU.arm64e.type, + cpuSubtype: CPU.arm64e.subtype, + reserved: wide ? 0 : null, + }, + { + index: 1, + offset: 8192, + size: x86.byteLength, + alignmentExponent: 12, + cpuType: CPU.x86_64.type, + cpuSubtype: CPU.x86_64.subtype, + reserved: wide ? 0x89abcdef : null, + }, + ], + }); + // Physical facts do not impose Hopper's reserved-field admission policy. + expect(classify(bytes)).toEqual({ state: "mach-o" }); + expect( + await readMachoImage(readerOf(bytes), bytes.byteLength), + ).toMatchObject({ + status: "parsed", + }); + }, + ); + + it("finds overlap in physical order while retaining declarations in table order", () => { + const bytes = fatImage([ + { cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }, + { cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) }, + ]); + const header = readFatContainerHeader(bytes, bytes.byteLength); + if (header.state !== "parsed") throw new Error("expected parsed FAT table"); + const first = { ...header.slices[0]!, offset: 128, size: 64 }; + const second = { ...header.slices[1]!, offset: 96, size: 64 }; + const tableOrder = [first, second]; + const overlap = findOverlappingFatSlices(tableOrder); + expect(overlap).toEqual([second, first]); + expect(overlap?.[0]).toBe(second); + expect(overlap?.[1]).toBe(first); + expect(tableOrder).toEqual([first, second]); + expect( + findOverlappingFatSlices([first, { ...second, size: 32 }]), + ).toBeNull(); + expect( + findOverlappingFatSlices([first, { ...second, size: 16 }]), + ).toBeNull(); + expect(findOverlappingFatSlices([first])).toBeNull(); + expect(findOverlappingFatSlices([])).toBeNull(); + }); +}); + describe("FAT Mach-O identity", () => { it("does not treat known CPU declarations as proof when slices are ar archives", async () => { const archive = arArchive(); diff --git a/src/domain/apple/machoContainer.ts b/src/domain/apple/machoContainer.ts index f4f1afaec1..2cee12db0a 100644 --- a/src/domain/apple/machoContainer.ts +++ b/src/domain/apple/machoContainer.ts @@ -12,6 +12,8 @@ export interface FatSliceDeclaration { readonly alignmentExponent: number; readonly cpuType: number; readonly cpuSubtype: number; + /** FAT64's raw reserved field; FAT32 has no corresponding field. */ + readonly reserved: number | null; } export interface FatSliceRangeIssue { @@ -273,11 +275,30 @@ export const readFatSliceDeclarations = ( alignmentExponent: view.getUint32(base + (wide ? 24 : 16), littleEndian), cpuType: view.getUint32(base, littleEndian), cpuSubtype: view.getUint32(base + 4, littleEndian), + reserved: wide ? view.getUint32(base + 28, littleEndian) : null, }); } return { status: "parsed", slices }; }; +/** Find an overlapping pair without changing the declarations' table order. */ +export const findOverlappingFatSlices = ( + declarations: readonly FatSliceDeclaration[], +): readonly [FatSliceDeclaration, FatSliceDeclaration] | null => { + const ranges = [...declarations].sort((a, b) => a.offset - b.offset); + for (let index = 1; index < ranges.length; index++) { + const previous = ranges[index - 1]; + const current = ranges[index]; + if ( + previous !== undefined && + current !== undefined && + current.offset < previous.offset + previous.size + ) + return [previous, current]; + } + return null; +}; + /** Validate byte-range and table/header identity facts shared by Mach-O readers. */ export const validateFatSlice = ( declaration: FatSliceDeclaration, diff --git a/src/hopper/HopperFileOffset.ts b/src/hopper/HopperFileOffset.ts index 99cc014974..885d33e311 100644 --- a/src/hopper/HopperFileOffset.ts +++ b/src/hopper/HopperFileOffset.ts @@ -25,10 +25,12 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { FileOffsetPartialObservation } from "../domain/native/fileOffsetPartialObservation.js"; import { + findOverlappingFatSlices, readFatSliceDeclarations, readMachoHeaderPrefixFacts, type FatSliceDeclaration, validateFatSlice, + validateFatSliceIdentity, } from "../domain/apple/machoContainer.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -351,17 +353,11 @@ function fatLayout(prefix: Buffer): Result { const table = prefix.subarray(8, tableEnd); const parsed = readFatSliceDeclarations(table, { count, wide, littleEndian }); if (parsed.status !== "parsed") return err(parsed.reason); - if (wide) { - for (const slice of parsed.slices) - if ( - (littleEndian - ? table.readUInt32LE(slice.index * 32 + 28) - : table.readUInt32BE(slice.index * 32 + 28)) !== 0 - ) - return err( - `FAT64 architecture ${slice.index} has a nonzero reserved field`, - ); - } + for (const slice of parsed.slices) + if (slice.reserved !== null && slice.reserved !== 0) + return err( + `FAT64 architecture ${slice.index} has a nonzero reserved field`, + ); return ok({ slices: parsed.slices, tableEnd }); } @@ -436,17 +432,8 @@ function originalOffset( "Hopper did not expose the loaded Mach-O header needed to identify its FAT slice", ); const matches = []; - const ranges = [...layout.value.slices].sort((a, b) => a.offset - b.offset); - for (let index = 1; index < ranges.length; index++) { - const previous = ranges[index - 1]; - const current = ranges[index]; - if ( - previous !== undefined && - current !== undefined && - current.offset < previous.offset + previous.size - ) - return err("Original FAT slices overlap"); - } + if (findOverlappingFatSlices(layout.value.slices) !== null) + return err("Original FAT slices overlap"); for (const slice of layout.value.slices) { const reason = validateFatSlice(slice, size, layout.value.tableEnd); if (reason !== null) return err(reason); @@ -460,12 +447,7 @@ function originalOffset( return err( `FAT architecture ${slice.index} does not contain a Mach-O image`, ); - const identityError = validateFatSlice( - slice, - size, - layout.value.tableEnd, - embeddedIdentity, - ); + const identityError = validateFatSliceIdentity(slice, embeddedIdentity); if (identityError !== null) return err(identityError); if ( slice.cpuType === loadedIdentity.cpuType && diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index ad2c9e3bf4..e41d62953b 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -14,9 +14,11 @@ import { BinaryTargetError } from "../domain/configurationErrors.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { + findOverlappingFatSlices, readFatSliceDeclarations, readMachoHeaderPrefixFacts, validateFatSlice, + validateFatSliceIdentity, type FatSliceDeclaration, } from "../domain/apple/machoContainer.js"; import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js"; @@ -366,7 +368,6 @@ const inspectHopperMachOImage = async ( const architecture = target.architecture; const cpuTypes = { x86: 7, x86_64: 0x1000007, arm: 12, arm64: 0x100000c }; const matches = []; - const ranges = []; for (const slice of parsed.slices) { if (signal?.aborted) return err( @@ -374,15 +375,10 @@ const inspectHopperMachOImage = async ( ); const rangeError = validateFatSlice(slice, size, tableEnd); if (rangeError !== null) return invalid(rangeError); - if (wide) { - const reserved = littleEndian - ? table.readUInt32LE(slice.index * 32 + 28) - : table.readUInt32BE(slice.index * 32 + 28); - if (reserved !== 0) - return invalid( - `FAT64 architecture ${slice.index} has a nonzero reserved field`, - ); - } + if (slice.reserved !== null && slice.reserved !== 0) + return invalid( + `FAT64 architecture ${slice.index} has a nonzero reserved field`, + ); if (slice.size < 12) return invalid( `FAT architecture ${slice.index} has a truncated Mach-O header`, @@ -395,22 +391,12 @@ const inspectHopperMachOImage = async ( return invalid( `FAT architecture ${slice.index} does not contain a Mach-O image`, ); - const identityError = validateFatSlice(slice, size, tableEnd, identity); + const identityError = validateFatSliceIdentity(slice, identity); if (identityError !== null) return invalid(identityError); - ranges.push(slice); if (slice.cpuType === cpuTypes[architecture]) matches.push(slice); } - ranges.sort((a, b) => a.offset - b.offset); - for (let index = 1; index < ranges.length; index++) { - const previous = ranges[index - 1]; - const current = ranges[index]; - if ( - previous !== undefined && - current !== undefined && - current.offset < previous.offset + previous.size - ) - return invalid("FAT slices overlap"); - } + if (findOverlappingFatSlices(parsed.slices) !== null) + return invalid("FAT slices overlap"); const selected = selectHopperSlice(matches, architecture); if (selected === undefined) return invalid( diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index 69c54725e9..bb4caafafd 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -70,6 +70,25 @@ const registerThinCpuFamilyControls = (): void => { }); }; +it.each([false, true])( + "retains the shared FAT declaration without imposing Hopper policy (wide=%s)", + (wide) => { + const thin = machoImage({ cpu: CPU.arm64e }); + const fat = Buffer.from(fatImage([{ cpu: CPU.arm64e, bytes: thin }], wide)); + if (wide) fat.writeUInt32BE(0x89abcdef, 8 + 28); + expect(selectMachoSlice(fat, "arm64").fatDeclaration).toEqual({ + index: 0, + offset: 4096, + size: thin.byteLength, + cpuType: CPU.arm64e.type, + cpuSubtype: CPU.arm64e.subtype, + alignmentExponent: 12, + reserved: wide ? 0x89abcdef : null, + tableEnd: 8 + (wide ? 32 : 20), + }); + }, +); + describe("native Mach-O slice selection", () => { it("rejects misaligned load commands", () => { expect(() => diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 5ca972f526..a81399a963 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -11,6 +11,7 @@ import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js"; import { readFatSliceDeclarations, validateFatSlice, + validateFatSliceIdentity, type FatSliceDeclaration, } from "../domain/apple/machoContainer.js"; @@ -34,15 +35,7 @@ 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; - }; + fatDeclaration?: FatSliceDeclaration & { readonly tableEnd: number }; } /** Parsed little-endian 64-bit Mach-O layout for the selected architecture slice. */ @@ -193,7 +186,7 @@ export const parseMachoLayout = ( const cpuSubtype = readHeaderUInt32(slice + 8); const fat = selection.fatDeclaration; if (fat !== undefined) { - const issue = validateFatSlice(fat, bytes.length, fat.tableEnd, { + const issue = validateFatSliceIdentity(fat, { cpuType, cpuSubtype, }); diff --git a/tests/boundary/filesystem/hopperFatCoordinates.test.ts b/tests/boundary/filesystem/hopperFatCoordinates.test.ts new file mode 100644 index 0000000000..87ea3e1ff7 --- /dev/null +++ b/tests/boundary/filesystem/hopperFatCoordinates.test.ts @@ -0,0 +1,229 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it, onTestFinished } from "vitest"; + +import { + CPU, + fatImage, + machoImage, +} from "../../../src/artifacts/apple/MachoImage.fixture.js"; +import type { BinaryTarget } from "../../../src/domain/binaryTargetTypes.js"; +import { mapHopperFileOffset } from "../../../src/hopper/HopperFileOffset.js"; +import { resolveHopperMachOImage } from "../../../src/hopper/HopperMachOImage.js"; +import { createInspectionResources } from "../../fixtures/inspectionResources.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const sourceTarget = async (bytes: Uint8Array, sliceSize: number) => { + const directory = await createTestTempDirectory( + "rea-hopper-fat-coordinates-", + ); + const path = join(directory, "source.fat"); + await writeFile(path, bytes); + return { + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + kind: "executable", + format: "mach-o-universal", + architecture: "arm64", + availableArchitectures: ["arm64"], + universalSlice: { offset: 4096, size: sliceSize }, + } satisfies BinaryTarget; +}; + +const resolveImage = ( + target: BinaryTarget & { readonly kind: "executable" }, +) => { + const resources = createInspectionResources(); + onTestFinished(() => resources.close()); + return resolveHopperMachOImage(target, { resourceScope: resources.owner }); +}; + +const loadedObservation = (header: Uint8Array) => ({ + address: "0x1000", + provider_file_offset: 8, + provider_source_path: null, + provider_image_header_hex: Buffer.from(header) + .subarray(0, 12) + .toString("hex"), +}); + +it.each([ + { wide: false, littleEndian: false }, + { wide: false, littleEndian: true }, + { wide: true, littleEndian: false }, + { wide: true, littleEndian: true }, +])( + "keeps launch selection separate from loaded-image coordinates (wide=$wide littleEndian=$littleEndian)", + async ({ wide, littleEndian }) => { + const plain = machoImage({ cpu: CPU.arm64 }); + const arm64e = machoImage({ cpu: CPU.arm64e }); + const bytes = fatImage( + [ + { cpu: CPU.arm64, bytes: plain }, + { cpu: CPU.arm64e, bytes: arm64e }, + ], + wide, + littleEndian, + ); + const target = await sourceTarget(bytes, plain.byteLength); + const selected = await resolveImage(target); + expect(selected).toEqual({ + ok: true, + value: { + method: wide ? "fat64_thin_slice" : "fat32_thin_slice", + architecture_count: 2, + container_table_sha256: createHash("sha256") + .update(bytes.subarray(0, 8 + 2 * (wide ? 32 : 20))) + .digest("hex"), + source_offset: 4096, + byte_length: plain.byteLength, + cpu_type: CPU.arm64.type, + cpu_subtype: CPU.arm64.subtype, + }, + }); + const mapped = await mapHopperFileOffset(target, loadedObservation(arm64e)); + expect(mapped).toEqual({ + ok: true, + value: { + address: "0x1000", + file_offset: 8192 + 8, + provider_file_offset: 8, + image_base_file_offset: 8192, + source_path: target.path, + }, + }); + }, +); + +it.each([false, true])( + "refuses a nonzero FAT64 reserved field in both consumers (littleEndian=%s)", + async (littleEndian) => { + const thin = machoImage({ cpu: CPU.arm64 }); + const bytes = fatImage( + [{ cpu: CPU.arm64, bytes: thin }], + true, + littleEndian, + ); + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32( + 8 + 28, + 0x89abcdef, + littleEndian, + ); + const target = await sourceTarget(bytes, thin.byteLength); + const reason = "FAT64 architecture 0 has a nonzero reserved field"; + expect(await resolveImage(target)).toMatchObject({ + ok: false, + error: { _tag: "BinaryTargetError", reason }, + }); + expect( + await mapHopperFileOffset(target, loadedObservation(thin)), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + reason, + partialObservation: { + ...loadedObservation(thin), + original_file_offset: { available: false, reason }, + }, + }, + }); + }, +); + +it.each([ + { wide: false, littleEndian: false }, + { wide: false, littleEndian: true }, + { wide: true, littleEndian: false }, + { wide: true, littleEndian: true }, +])( + "refuses overlapping valid slice headers in both consumers (wide=$wide littleEndian=$littleEndian)", + async ({ wide, littleEndian }) => { + const plain = machoImage({ cpu: CPU.arm64 }); + const arm64e = machoImage({ cpu: CPU.arm64e }); + const bytes = fatImage( + [ + { cpu: CPU.arm64, bytes: plain }, + { cpu: CPU.arm64e, bytes: arm64e }, + ], + wide, + littleEndian, + ); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + // Both headers and ranges remain valid; the first range contains the second. + if (wide) + view.setBigUint64( + 8 + 16, + 4096n + BigInt(arm64e.byteLength), + littleEndian, + ); + else view.setUint32(8 + 12, 4096 + arm64e.byteLength, littleEndian); + const target = await sourceTarget(bytes, 4096 + arm64e.byteLength); + expect(await resolveImage(target)).toMatchObject({ + ok: false, + error: { _tag: "BinaryTargetError", reason: "FAT slices overlap" }, + }); + expect( + await mapHopperFileOffset(target, loadedObservation(arm64e)), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + reason: "Original FAT slices overlap", + }, + }); + }, +); + +it("requires a unique exact raw CPU identity for loaded-image attribution", async () => { + const arm64e = machoImage({ cpu: CPU.arm64e }); + const bytes = fatImage([ + { cpu: CPU.arm64e, bytes: arm64e }, + { cpu: CPU.arm64e, bytes: arm64e }, + ]); + const target = await sourceTarget(bytes, arm64e.byteLength); + expect( + await mapHopperFileOffset(target, loadedObservation(arm64e)), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + reason: "Original FAT slice matching Hopper's loaded header is ambiguous", + }, + }); + const withoutCapabilities = Buffer.from(arm64e); + withoutCapabilities.writeUInt32LE(2, 8); + expect( + await mapHopperFileOffset(target, loadedObservation(withoutCapabilities)), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + reason: "Original FAT slice matching Hopper's loaded header is missing", + }, + }); +}); + +it("checks all 12 loaded-header bytes after matching its CPU identity", async () => { + const thin = machoImage({ cpu: CPU.arm64 }); + const target = await sourceTarget( + fatImage([{ cpu: CPU.arm64, bytes: thin }]), + thin.byteLength, + ); + const loadedHeader = Buffer.alloc(12); + // A big-endian header claims the same CPU identity but is not the source header. + loadedHeader.writeUInt32BE(0xfeedfacf, 0); + loadedHeader.writeUInt32BE(CPU.arm64.type, 4); + loadedHeader.writeUInt32BE(CPU.arm64.subtype, 8); + expect( + await mapHopperFileOffset(target, loadedObservation(loadedHeader)), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + reason: "Original FAT slice header disagrees with Hopper's loaded header", + }, + }); +});