diff --git a/src/domain/apple/machoContainer.ts b/src/domain/apple/machoContainer.ts index 4b2567603c..71a506ab6b 100644 --- a/src/domain/apple/machoContainer.ts +++ b/src/domain/apple/machoContainer.ts @@ -112,18 +112,62 @@ const MACHO_HEADER_MAGICS: readonly number[] = [ 0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, ]; +interface FatHeaderFormat { + readonly magic: number; + readonly wide: boolean; + readonly littleEndian: boolean; +} + +export type FatHeaderPrefix = + | { readonly state: "unknown"; readonly requiredBytes: 4 } + | { readonly state: "not-fat"; readonly magic: number } + | (FatHeaderFormat & + ( + | { readonly state: "incomplete"; readonly requiredBytes: 8 } + | { + readonly state: "parsed"; + readonly count: number; + readonly tableEnd: number; + } + )); + +/** Observe the fixed FAT header without table, count-limit or container-identity admission. */ +export const readFatHeaderPrefix = (bytes: Uint8Array): FatHeaderPrefix => { + if (bytes.byteLength < 4) return { state: "unknown", requiredBytes: 4 }; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const magic = view.getUint32(0, false); + if (![FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic)) + return { state: "not-fat", magic }; + const format = { + magic, + wide: magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64, + littleEndian: magic === FAT_CIGAM || magic === FAT_CIGAM_64, + }; + if (bytes.byteLength < 8) + return { state: "incomplete", ...format, requiredBytes: 8 }; + const count = view.getUint32(4, format.littleEndian); + return { + state: "parsed", + ...format, + count, + tableEnd: 8 + count * (format.wide ? 32 : 20), + }; +}; + /** Parse a bounded FAT header and table without treating the magic alone as proof. */ export const readFatContainerHeader = ( bytes: Uint8Array, fileSize: number, ): FatContainerHeader => { - if (!hasFatMagic(bytes)) return { state: "not-fat" }; + const prefix = readFatHeaderPrefix(bytes); + if (prefix.state === "unknown" || prefix.state === "not-fat") + return { state: "not-fat" }; if (!Number.isSafeInteger(fileSize) || fileSize < bytes.byteLength) return { state: "malformed", reason: "FAT file size is invalid or contradicts retained bytes", }; - const table = readFatContainerTable(bytes); + const table = decodeFatContainerTable(bytes, prefix); if (table.state !== "parsed") { if (table.state === "needs-data" && table.requiredBytes > fileSize) { return table.requiredBytes === 8 && fileSize < 8 @@ -144,23 +188,24 @@ export const readFatContainerHeader = ( }; /** Decode bounded FAT declarations without assuming the final file size. */ -export const readFatContainerTable = (bytes: Uint8Array): FatContainerTable => { - if (!hasFatMagic(bytes)) return { state: "not-fat" }; - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const magic = view.getUint32(0, false); - if (bytes.byteLength < 8) { - return { state: "needs-data", requiredBytes: 8 }; - } - const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64; - const wide = magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64; - const count = view.getUint32(4, littleEndian); +export const readFatContainerTable = (bytes: Uint8Array): FatContainerTable => + decodeFatContainerTable(bytes, readFatHeaderPrefix(bytes)); + +const decodeFatContainerTable = ( + bytes: Uint8Array, + prefix: FatHeaderPrefix, +): FatContainerTable => { + if (prefix.state === "unknown" || prefix.state === "not-fat") + return { state: "not-fat" }; + if (prefix.state === "incomplete") + return { state: "needs-data", requiredBytes: prefix.requiredBytes }; + const { magic, littleEndian, wide, count, tableEnd } = prefix; if (count === 0) return { state: "malformed", reason: "FAT header declares no architectures", }; if (count > MAX_FAT_ARCHITECTURES) return { state: "not-fat" }; - const tableEnd = 8 + count * (wide ? 32 : 20); if (tableEnd > bytes.byteLength) return { state: "needs-data", requiredBytes: tableEnd }; const declarations = readFatSliceDeclarations(bytes.subarray(8, tableEnd), { @@ -181,14 +226,6 @@ export const readFatContainerTable = (bytes: Uint8Array): FatContainerTable => { : { state: "malformed", reason: declarations.reason }; }; -const hasFatMagic = (bytes: Uint8Array): boolean => { - if (bytes.byteLength < 4) return false; - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return [FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes( - view.getUint32(0, false), - ); -}; - /** Confirm FAT identity from observed slice headers, not CPU declarations. */ export const classifyFatContainerIdentity = ( header: Extract, diff --git a/src/domain/apple/machoFatHeader.test.ts b/src/domain/apple/machoFatHeader.test.ts new file mode 100644 index 0000000000..838498edf1 --- /dev/null +++ b/src/domain/apple/machoFatHeader.test.ts @@ -0,0 +1,77 @@ +import { expect, it } from "vitest"; +import { + readFatHeaderPrefix, + readFatContainerHeader, +} from "./machoContainer.js"; + +const formats = [ + { magic: 0xcafebabe, wide: false, littleEndian: false }, + { magic: 0xcafebabf, wide: true, littleEndian: false }, + { magic: 0xbebafeca, wide: false, littleEndian: true }, + { magic: 0xbfbafeca, wide: true, littleEndian: true }, +] as const; + +it.each(formats)( + "preserves incomplete FAT format evidence ($magic)", + (format) => { + const bytes = Buffer.alloc(8); + bytes.writeUInt32BE(format.magic); + for (let observed = 0; observed < 8; observed++) + expect(readFatHeaderPrefix(bytes.subarray(0, observed))).toEqual( + observed < 4 + ? { state: "unknown", requiredBytes: 4 } + : { state: "incomplete", ...format, requiredBytes: 8 }, + ); + }, +); + +it.each(formats)( + "retains raw zero and maximum counts without policy or table allocation ($magic)", + (format) => { + const backing = Buffer.alloc(27, 0xa5); + const window = backing.subarray(7, 15); + const view = new DataView( + window.buffer, + window.byteOffset, + window.byteLength, + ); + view.setUint32(0, format.magic, false); + for (const count of [0, 0xffffffff]) { + view.setUint32(4, count, format.littleEndian); + const before = Buffer.from(backing); + const parsed = readFatHeaderPrefix(backing.subarray(7)); + expect(backing.equals(before)).toBe(true); + expect(parsed).toEqual({ + state: "parsed", + ...format, + count, + tableEnd: 8 + count * (format.wide ? 32 : 20), + }); + } + }, +); + +it("distinguishes observed non-FAT bytes from unavailable magic", () => { + const bytes = Buffer.from([0x7f, 0x45, 0x4c, 0x46]); + expect(readFatHeaderPrefix(bytes)).toEqual({ + state: "not-fat", + magic: 0x7f454c46, + }); + expect(readFatHeaderPrefix(bytes.subarray(0, 3))).toEqual({ + state: "unknown", + requiredBytes: 4, + }); +}); + +it("keeps full-header file extent admission ahead of zero-count admission", () => { + const bytes = Buffer.alloc(8); + bytes.writeUInt32BE(0xcafebabe); + expect(readFatContainerHeader(bytes, 7)).toEqual({ + state: "malformed", + reason: "FAT file size is invalid or contradicts retained bytes", + }); + expect(readFatContainerHeader(bytes, 8)).toEqual({ + state: "malformed", + reason: "FAT header declares no architectures", + }); +}); diff --git a/src/domain/dosMz.test.ts b/src/domain/dosMz.test.ts index 00f444430a..1f152cf8a6 100644 --- a/src/domain/dosMz.test.ts +++ b/src/domain/dosMz.test.ts @@ -21,6 +21,7 @@ describe("DOS MZ load-module validation", () => { overlayBytes: 7, relocationCount: 0, relocationTableOffset: 28, + relocations: [], entrySegment: 0, entryOffset: 0, }, @@ -380,3 +381,41 @@ describe("MZ header padding observations", () => { expect(parseExecutableHeader(bytes, "x64")).toMatchObject({ ok: false }); }); }); + +it("retains ordered DOS relocation aliases and their source/module target coordinates", () => { + const bytes = Buffer.alloc(128); + bytes.write("MZ"); + bytes.writeUInt16LE(128, 2); + bytes.writeUInt16LE(1, 4); + bytes.writeUInt16LE(4, 6); + bytes.writeUInt16LE(4, 8); + bytes.writeUInt16LE(28, 24); + bytes.writeUInt16LE(0, 28); + bytes.writeUInt16LE(1, 30); + bytes.writeUInt16LE(16, 32); + bytes.writeUInt16LE(0, 34); + bytes.writeUInt16LE(16, 36); + bytes.writeUInt16LE(0, 38); + bytes.writeUInt16LE(17, 40); + bytes.writeUInt16LE(0, 42); + const parsed = parseDosMzHeader(bytes); + if (!parsed.ok) throw new Error(parsed.error); + expect(parsed.value.relocations).toEqual([ + { segment: 1, offset: 0, moduleOffset: 16, fileOffset: 80 }, + { segment: 0, offset: 16, moduleOffset: 16, fileOffset: 80 }, + { segment: 0, offset: 16, moduleOffset: 16, fileOffset: 80 }, + { segment: 0, offset: 17, moduleOffset: 17, fileOffset: 81 }, + ]); + // Parsed facts are snapshots, independent of later source-buffer edits. + bytes.writeUInt16LE(63, 32); + expect(parsed.value.relocations[1]).toEqual({ + segment: 0, + offset: 16, + moduleOffset: 16, + fileOffset: 80, + }); + expect(parseDosMzHeader(bytes)).toEqual({ + ok: false, + error: "DOS MZ relocation 1 points outside its load module", + }); +}); diff --git a/src/domain/dosMz.ts b/src/domain/dosMz.ts index 870e315c5c..2171505c12 100644 --- a/src/domain/dosMz.ts +++ b/src/domain/dosMz.ts @@ -11,6 +11,14 @@ export const dosMzImageByteLength = (bytes: Buffer): Result => { return ok((pages - 1) * 512 + (lastPageBytes === 0 ? 512 : lastPageBytes)); }; +/** One validated relocation, retaining raw words and both target coordinate spaces. */ +export interface DosMzRelocation { + readonly segment: number; + readonly offset: number; + readonly moduleOffset: number; + readonly fileOffset: number; +} + /** DOS load-module coordinates before applying the loader's segment relocation. */ export interface DosMzHeader { readonly headerBytes: number; @@ -19,6 +27,7 @@ export interface DosMzHeader { readonly overlayBytes: number; readonly relocationCount: number; readonly relocationTableOffset: number; + readonly relocations: readonly DosMzRelocation[]; readonly entrySegment: number; readonly entryOffset: number; } @@ -174,10 +183,11 @@ export const parseDosMzHeader = ( if (relocationCount > 0 && tableEnd > bytes.length) return err("truncated DOS MZ relocation table"); const moduleBytes = imageBytes - headerBytes; - const relocations = validateRelocations( + const relocations = readRelocations( bytes, relocationTableOffset, relocationCount, + headerBytes, moduleBytes, ); if (!relocations.ok) return relocations; @@ -192,6 +202,7 @@ export const parseDosMzHeader = ( overlayBytes: fileSize - imageBytes, relocationCount, relocationTableOffset, + relocations: relocations.value, entrySegment, entryOffset, }); @@ -212,18 +223,27 @@ export const parseMzHeaderEvidence = ( windowsHeader: observeMzWindowsHeader(bytes, fileSize, signatureEvidence), }); -const validateRelocations = ( +const readRelocations = ( bytes: Buffer, relocationTableOffset: number, relocationCount: number, + headerBytes: number, moduleBytes: number, -): Result => { +): Result => { + const relocations: DosMzRelocation[] = []; for (let index = 0; index < relocationCount; index += 1) { const record = relocationTableOffset + index * 4; const offset = bytes.readUInt16LE(record); const segment = bytes.readUInt16LE(record + 2); - if (segment * 16 + offset + 2 > moduleBytes) + const moduleOffset = segment * 16 + offset; + if (moduleOffset + 2 > moduleBytes) return err(`DOS MZ relocation ${index} points outside its load module`); + relocations.push({ + segment, + offset, + moduleOffset, + fileOffset: headerBytes + moduleOffset, + }); } - return ok(undefined); + return ok(relocations); }; diff --git a/src/dotnet/ManagedMethodBodyReader.ts b/src/dotnet/ManagedMethodBodyReader.ts index e7aacddc8b..c57f895b73 100644 --- a/src/dotnet/ManagedMethodBodyReader.ts +++ b/src/dotnet/ManagedMethodBodyReader.ts @@ -136,8 +136,10 @@ export const methodBody = ( }; } try { - const offset = pe.rvaToOffset(rva, 1, "method.body"); - const methodExtent = pe.rvaAvailableBytes(rva, "method.body"); + const { offset, available: methodExtent } = pe.rvaFileRange( + rva, + "method.body", + ); const header = readMethodBodyHeader(bytes, offset); if ( header.size > methodExtent || diff --git a/src/dotnet/ManagedPeReader.ts b/src/dotnet/ManagedPeReader.ts index 5f0db524aa..fcb9518c50 100644 --- a/src/dotnet/ManagedPeReader.ts +++ b/src/dotnet/ManagedPeReader.ts @@ -72,7 +72,10 @@ export interface ManagedPeLayout { readonly cliDirectoryState: "absent" | "present" | "unreadable"; readonly cliIssue: ManagedParseIssue | null; rvaToOffset(rva: number, size: number, scope: string): number; - rvaAvailableBytes(rva: number, scope: string): number; + rvaFileRange( + rva: number, + scope: string, + ): { readonly offset: number; readonly available: number }; } interface RvaMapping { @@ -496,7 +499,7 @@ export const readManagedPeLayout = (bytes: Buffer): ManagedPeLayout => { cliIssue, rvaToOffset: (rva, size, scope) => mapRva({ bytes, sections, rva, size, scope }), - rvaAvailableBytes: (rva, scope) => - resolveRva({ bytes, sections, rva, size: 1, scope }).available, + rvaFileRange: (rva, scope) => + resolveRva({ bytes, sections, rva, size: 1, scope }), }; }; diff --git a/src/ghidra/GhidraLoadImageValues.test.ts b/src/ghidra/GhidraLoadImageValues.test.ts index 4d99667c54..86f65cf5b7 100644 --- a/src/ghidra/GhidraLoadImageValues.test.ts +++ b/src/ghidra/GhidraLoadImageValues.test.ts @@ -486,3 +486,73 @@ describe("independent DOS MZ import verification", () => { ).toBe(false); }); }); + +it("applies duplicate aliases and overlapping DOS fixups in declared order with original/final byte evidence", () => { + const bytes = Buffer.alloc(128); + bytes.write("MZ"); + bytes.writeUInt16LE(128, 2); + bytes.writeUInt16LE(1, 4); + bytes.writeUInt16LE(4, 6); + bytes.writeUInt16LE(4, 8); + bytes.writeUInt16LE(28, 24); + bytes.writeUInt16LE(0, 28); + bytes.writeUInt16LE(1, 30); + bytes.writeUInt16LE(16, 32); + bytes.writeUInt16LE(0, 34); + bytes.writeUInt16LE(16, 36); + bytes.writeUInt16LE(0, 38); + bytes.writeUInt16LE(17, 40); + bytes.writeUInt16LE(0, 42); + bytes.set([1, 0, 2], 80); + const before = Buffer.from(bytes); + const prepared = prepareGhidraDosLoadImage(bytes, bytes.length); + if (!prepared.ok) throw new Error(prepared.error); + expect(bytes.equals(before)).toBe(true); + expect( + prepared.value.modified + .subarray(80, 83) + .equals(Buffer.from([1, 0x30, 0x12])), + ).toBe(true); + expect(prepared.value.expectedRelocations).toEqual([ + { + address: "0x10010", + segment: 0x1001, + offset: 0, + status: "APPLIED", + type: 0, + values: [1, 0, 0x1001], + original_bytes_hex: "0100", + memory_bytes_hex: "0130", + }, + { + address: "0x10010", + segment: 0x1000, + offset: 16, + status: "APPLIED", + type: 0, + values: [0, 16, 0x2001], + original_bytes_hex: "0110", + memory_bytes_hex: "0130", + }, + { + address: "0x10010", + segment: 0x1000, + offset: 16, + status: "APPLIED", + type: 0, + values: [0, 16, 0x3001], + original_bytes_hex: "0120", + memory_bytes_hex: "0130", + }, + { + address: "0x10011", + segment: 0x1000, + offset: 17, + status: "APPLIED", + type: 0, + values: [0, 17, 0x1230], + original_bytes_hex: "3002", + memory_bytes_hex: "3012", + }, + ]); +}); diff --git a/src/ghidra/GhidraLoadImageValues.ts b/src/ghidra/GhidraLoadImageValues.ts index 2d8b81aaa3..bd345a24ba 100644 --- a/src/ghidra/GhidraLoadImageValues.ts +++ b/src/ghidra/GhidraLoadImageValues.ts @@ -78,24 +78,32 @@ export const prepareGhidraDosLoadImage = ( const loadSegment = 0x1000; const modified = Buffer.from(bytes.subarray(0, header.imageBytes)); const applied = []; - for (let index = 0; index < header.relocationCount; index += 1) { - const table = header.relocationTableOffset + index * 4; - const offset = bytes.readUInt16LE(table); - const segment = bytes.readUInt16LE(table + 2); - const fileOffset = header.headerBytes + segment * 16 + offset; + for (const { + segment, + offset, + moduleOffset, + fileOffset, + } of header.relocations) { const original = modified .subarray(fileOffset, fileOffset + 2) .toString("hex"); const relocated = (modified.readUInt16LE(fileOffset) + loadSegment) & 0xffff; modified.writeUInt16LE(relocated, fileOffset); - applied.push({ segment, offset, fileOffset, original, relocated }); + applied.push({ + segment, + offset, + moduleOffset, + fileOffset, + original, + relocated, + }); } // Application records describe their own fixup; the bridge reads memory // once all fixups have completed, including later overlapping writes. const expectedRelocations = applied.map( - ({ segment, offset, fileOffset, original, relocated }) => ({ - address: address(loadSegment * 16 + segment * 16 + offset), + ({ segment, offset, moduleOffset, fileOffset, original, relocated }) => ({ + address: address(loadSegment * 16 + moduleOffset), segment: (loadSegment + segment) & 0xffff, offset, status: "APPLIED", diff --git a/src/hopper/HopperFileOffset.ts b/src/hopper/HopperFileOffset.ts index 885d33e311..f4f5753ae7 100644 --- a/src/hopper/HopperFileOffset.ts +++ b/src/hopper/HopperFileOffset.ts @@ -27,6 +27,7 @@ import type { FileOffsetPartialObservation } from "../domain/native/fileOffsetPa import { findOverlappingFatSlices, readFatSliceDeclarations, + readFatHeaderPrefix, readMachoHeaderPrefixFacts, type FatSliceDeclaration, validateFatSlice, @@ -340,16 +341,13 @@ interface FatLayout { } function fatLayout(prefix: Buffer): Result { - const magic = prefix.length < 4 ? 0 : prefix.readUInt32BE(0); - if (![0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic)) - return ok(null); - if (prefix.length < 8) return err("The original FAT header is truncated"); - const littleEndian = magic === 0xbebafeca || magic === 0xbfbafeca; - const wide = magic === 0xcafebabf || magic === 0xbfbafeca; - const count = littleEndian ? prefix.readUInt32LE(4) : prefix.readUInt32BE(4); + const header = readFatHeaderPrefix(prefix); + if (header.state === "unknown" || header.state === "not-fat") return ok(null); + if (header.state === "incomplete") + return err("The original FAT header is truncated"); + const { littleEndian, wide, count, tableEnd } = header; if (count === 0 || count > MAX_FAT_ARCHITECTURES) return err("FAT architecture count is outside the supported 1..128 range"); - const tableEnd = 8 + count * (wide ? 32 : 20); const table = prefix.subarray(8, tableEnd); const parsed = readFatSliceDeclarations(table, { count, wide, littleEndian }); if (parsed.status !== "parsed") return err(parsed.reason); diff --git a/src/hopper/HopperMachOImage.test.ts b/src/hopper/HopperMachOImage.test.ts index fcac956979..47c94a7ffe 100644 --- a/src/hopper/HopperMachOImage.test.ts +++ b/src/hopper/HopperMachOImage.test.ts @@ -786,3 +786,26 @@ it("retains an owned partial thin image and exact cancellation reason after a wr expect(Buffer.compare(await readFile(destination), partial)).toBe(0); await expect(access(destination)).resolves.toBeUndefined(); }); + +it.each([0, 129])( + "keeps Hopper FAT count admission explicit for count %i", + async (count) => { + const directory = await createTestTempDirectory("rea-hopper-fat-count-"); + const fixture = buildFat32Container([ + { cpuType: CPU_TYPES.arm64, cpuSubtype: 0 }, + ]); + fixture.container.writeUInt32BE(count, 4); + const path = await writeFixture(directory, "counts.fat", fixture); + const result = await resolveHopperMachOImage( + fat32Target(path, fixture, "arm64"), + ); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Invalid FAT count was admitted"); + expect(result.error).toMatchObject({ + _tag: "BinaryTargetError", + path, + reason: + "FAT architecture table is truncated or exceeds the supported 128 entries", + }); + }, +); diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index 5dcb6d11d1..d8bcaefb4c 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -19,6 +19,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { findOverlappingFatSlices, readFatSliceDeclarations, + readFatHeaderPrefix, readMachoHeaderPrefixFacts, validateFatSlice, validateFatSliceIdentity, @@ -285,7 +286,9 @@ const inspectHopperMachOImage = async ( return bytes.subarray(0, bytesRead); }; const header = await read(0, 8); - const magic = header.length === 8 ? header.readUInt32BE(0) : 0; + const fatPrefix = readFatHeaderPrefix(header); + const magic = + header.length === 8 && fatPrefix.state !== "unknown" ? fatPrefix.magic : 0; if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) { if (target.format !== "mach-o") return invalid("Thin Mach-O source disagrees with its admitted format"); @@ -336,24 +339,17 @@ const inspectHopperMachOImage = async ( cpu_subtype: facts.identity.cpuSubtype, }); } - const isFatMagic = [0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca].includes( - magic, - ); - if (!isFatMagic) + if (fatPrefix.state !== "parsed") return invalid( "Mach-O source header changed before Hopper image preparation", ); - const wide = magic === 0xcafebabf || magic === 0xbfbafeca; - const littleEndian = magic === 0xbebafeca || magic === 0xbfbafeca; - const entrySize = wide ? 32 : 20; - const count = littleEndian ? header.readUInt32LE(4) : header.readUInt32BE(4); + const { wide, littleEndian, count, tableEnd } = fatPrefix; const size = (await file.stat()).size; - const tableEnd = 8 + count * entrySize; if (count === 0 || count > 128 || tableEnd > size) return invalid( "FAT architecture table is truncated or exceeds the supported 128 entries", ); - const table = await read(8, count * entrySize); + const table = await read(8, tableEnd - 8); const parsed = readFatSliceDeclarations(table, { count, wide, diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index 4f01078602..3fa6738afd 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -1,3 +1,4 @@ +import { readFatContainerHeader } from "../domain/apple/machoContainer.js"; import { describe, expect, it } from "vitest"; import { @@ -340,3 +341,26 @@ describe("native Mach-O fixup load command boundaries", () => { ]); }); }); + +it.each([0, 129])( + "preserves native FAT selection policy separately from generic identity admission (count %i)", + (count) => { + const bytes = Buffer.alloc(8); + bytes.writeUInt32BE(0xcafebabe); + bytes.writeUInt32BE(count, 4); + expect(readFatContainerHeader(bytes, bytes.length)).toEqual( + count === 0 + ? { state: "malformed", reason: "FAT header declares no architectures" } + : { state: "not-fat" }, + ); + expect(() => selectMachoSlice(bytes, "arm64")).toThrow( + count === 0 + ? new TypeError( + "Requested FAT architecture is absent (FAT header byte order: big-endian)", + ) + : new RangeError( + "Malformed FAT architecture table (FAT header byte order: big-endian)", + ), + ); + }, +); diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 390ac96761..413ccecba7 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -15,6 +15,7 @@ import { import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js"; import { readFatSliceDeclarations, + readFatHeaderPrefix, validateFatSlice, validateFatSliceIdentity, type FatSliceDeclaration, @@ -66,45 +67,24 @@ export interface MachoLayout { const hex = (value: bigint) => `0x${value.toString(16)}`; -/** Classify a FAT magic word; thin Mach-O returns null. */ -export const fatHeaderFormat = ( - magic: number, -): { readonly littleEndian: boolean; readonly fat64: boolean } | null => { - switch (magic) { - case 0xcafebabe: - return { littleEndian: false, fat64: false }; - case 0xcafebabf: - return { littleEndian: false, fat64: true }; - case 0xbebafeca: - return { littleEndian: true, fat64: false }; - case 0xbfbafeca: - return { littleEndian: true, fat64: true }; - default: - return null; - } -}; - /** Select the thin Mach-O slice for `architecture` inside a FAT or thin container. */ export const selectMachoSlice = ( bytes: Buffer, architecture: string, ): MachoSlice => { - const magic = bytes.readUInt32BE(0); - const header = fatHeaderFormat(magic); - if (header === null) return { slice: 0, sliceEnd: bytes.length }; - const { littleEndian, fat64 } = header; - const stride = fat64 ? 32 : 20; - const readUInt32 = littleEndian - ? (offset: number) => bytes.readUInt32LE(offset) - : (offset: number) => bytes.readUInt32BE(offset); + const header = readFatHeaderPrefix(bytes); + if (header.state === "unknown") + throw new RangeError("Truncated Mach-O header"); + if (header.state === "not-fat") return { slice: 0, sliceEnd: bytes.length }; + const { littleEndian, wide } = header; const byteOrder = littleEndian ? "little-endian" : "big-endian"; const malformed = (reason: string) => new RangeError(`${reason} (FAT header byte order: ${byteOrder})`); const invalid = (reason: string) => new TypeError(`${reason} (FAT header byte order: ${byteOrder})`); - if (bytes.length < 8) throw malformed("Malformed FAT architecture table"); - const count = readUInt32(4); - const headerEnd = 8 + count * stride; + if (header.state === "incomplete") + throw malformed("Malformed FAT architecture table"); + const { count, tableEnd: headerEnd } = header; if (count > 128 || headerEnd > bytes.length) throw malformed("Malformed FAT architecture table"); const policy = MACHO_ARCHITECTURE_POLICIES[architecture]; @@ -112,7 +92,7 @@ export const selectMachoSlice = ( throw invalid("Requested FAT architecture is absent"); const declarations = readFatSliceDeclarations(bytes.subarray(8, headerEnd), { count, - wide: fat64, + wide, littleEndian, }); if (declarations.status !== "parsed") throw malformed(declarations.reason);