From 497037e6e3975cbb37cfd89ea0ad300faa2a4769 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:18:03 +0800 Subject: [PATCH] fix(macho): validate FAT slice format and CPU identity Use observed thin-header facts before target architecture admission, reject archive payloads, and retain far-offset root inventory through bounded reads. Retry complete bounded FAT table reads. Refs #5462, #5542. Focused native/filesystem checks passed (245 plus 70 after the table retry). Public MCP inventory parity and unchanged token estimates verified; no external analysis engine execution claimed. --- src/application/BinaryTargetResolver.ts | 101 ++++++-- src/artifacts/ArtifactClassificationProbe.ts | 133 +++++++++- src/artifacts/apple/MachoImage.fixture.ts | 19 ++ src/artifacts/apple/machoContainer.test.ts | 235 ++++++++++++++++++ .../ArtifactGraphConstruction.test.ts | 131 +++++++++- .../inventory/ArtifactGraphConstruction.ts | 34 ++- src/artifacts/inventory/classify.ts | 59 ++++- src/domain/apple/machoContainer.ts | 126 ++++++++-- src/domain/binaryTarget.test.ts | 4 +- src/domain/binaryTarget.ts | 76 +++++- .../artifactInventoryRegularRoot.test.ts | 28 +++ .../boundary/filesystem/binaryTarget.test.ts | 128 ++++++++++ 12 files changed, 1003 insertions(+), 71 deletions(-) create mode 100644 src/artifacts/apple/machoContainer.test.ts diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index d93cb14e7d..ac8e9f454e 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -53,6 +53,12 @@ import { peRvaToFileOffset, type ExecutableMetadata, } from "../domain/binaryTarget.js"; +import { + readFatContainerHeader, + readFatSliceHeaderFacts, + type FatSliceHeaderEvidence, + type FatSliceHeaderFacts, +} from "../domain/apple/machoContainer.js"; import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; @@ -532,25 +538,19 @@ const readExecutableMetadata = async ( signatureBytes, ); } - if (bytes.length >= 8) { - const magic = bytes.readUInt32BE(0); - if ([0xcafebabf, 0xbfbafeca].includes(magic)) { - const little = magic === 0xbfbafeca; - const count = little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4); - const required = 8 + count * 32; - if (count <= 128 && required > bytes.length) { - const header = Buffer.alloc(required); - const headerRead = await handle.read(header, 0, header.length, 0); - throwIfTargetResolutionCancelled(signal); - return parseExecutableHeader( - header.subarray(0, headerRead.bytesRead), - hostArchitecture, - fileSize, - ); - } - } - } - const parsed = parseExecutableHeader(bytes, hostArchitecture, fileSize); + const fatEvidence = await readFatSliceHeaderEvidence( + handle, + bytes, + fileSize, + signal, + ); + const parsed = parseExecutableHeader( + bytes, + hostArchitecture, + fileSize, + undefined, + fatEvidence, + ); if ( !parsed.ok || parsed.value.architecture !== "mips" || @@ -579,6 +579,69 @@ const readExecutableMetadata = async ( }); }; +/** Read the validated FAT table and bounded thin-header identity prefixes. */ +const readFatSliceHeaderEvidence = async ( + handle: FileHandle, + prefix: Buffer, + fileSize: number, + signal?: AbortSignal, +): Promise => { + let header = readFatContainerHeader(prefix, fileSize); + if (header.state === "needs-data") { + // readFatContainerHeader enforces the 128-entry format bound before this + // allocation; this is only the table, never a synthesized file image. + const table = Buffer.alloc(header.requiredBytes); + let bytesRead = 0; + while (bytesRead < table.length) { + throwIfTargetResolutionCancelled(signal); + const read = await handle.read( + table, + bytesRead, + table.length - bytesRead, + bytesRead, + ); + throwIfTargetResolutionCancelled(signal); + if (read.bytesRead === 0) break; + bytesRead += read.bytesRead; + } + header = readFatContainerHeader(table.subarray(0, bytesRead), fileSize); + } + if (header.state !== "parsed") return undefined; + + const sliceHeadersByOffset = new Map(); + for (let index = 0; index < header.slices.length; index += 1) { + const slice = header.slices[index]; + if (slice === undefined) continue; + if (header.rangeIssues[index] !== null) { + sliceHeadersByOffset.set( + slice.offset, + readFatSliceHeaderFacts(Buffer.alloc(0)), + ); + continue; + } + const observedLength = Math.min(12, slice.size); + const thinHeader = Buffer.alloc(observedLength); + let bytesRead = 0; + while (bytesRead < thinHeader.length) { + throwIfTargetResolutionCancelled(signal); + const read = await handle.read( + thinHeader, + bytesRead, + thinHeader.length - bytesRead, + slice.offset + bytesRead, + ); + throwIfTargetResolutionCancelled(signal); + if (read.bytesRead === 0) break; + bytesRead += read.bytesRead; + } + sliceHeadersByOffset.set( + slice.offset, + readFatSliceHeaderFacts(thinHeader.subarray(0, bytesRead)), + ); + } + return { header, sliceHeadersByOffset }; +}; + const readPeMetadata = async ( handle: FileHandle, offset: number, diff --git a/src/artifacts/ArtifactClassificationProbe.ts b/src/artifacts/ArtifactClassificationProbe.ts index bc661f3787..3195db8c56 100644 --- a/src/artifacts/ArtifactClassificationProbe.ts +++ b/src/artifacts/ArtifactClassificationProbe.ts @@ -6,16 +6,104 @@ import { createXmlPlistSignatureProbe, MAX_XML_PLIST_PROBE_BYTES, } from "../domain/apple/appleArchiveFormat.js"; +import { + readFatContainerHeader, + readFatContainerTable, + readFatSliceHeaderFacts, + type FatContainerTable, + type FatSliceHeaderFacts, + type FatSliceHeaderEvidence, +} from "../domain/apple/machoContainer.js"; /** Initial bytes required by the other bounded format classifiers. */ export const ARTIFACT_CLASSIFICATION_PREFIX_BYTES = 8_192; -/** Retained bytes and independently parsed ASAR signature facts. */ +/** Retained prefix plus bounded format-specific observations from the stream. */ export interface ArtifactClassificationEvidence { readonly prefix: Buffer; readonly asarHeader: FinalAsarHeaderProbeObservation; + readonly fatSliceHeaders?: FatSliceHeaderEvidence; } +const observeFatSliceHeaders = (input: { + readonly table: FatContainerTable | undefined; + readonly observedHeaders: Map; + readonly partialHeaders: Map< + number, + { readonly bytes: Buffer; count: number } + >; + readonly streamOffset: number; + readonly bytes: Uint8Array; +}): void => { + const { table, observedHeaders, partialHeaders, streamOffset, bytes } = input; + if (table?.state !== "parsed") return; + for (const slice of table.slices) { + if (observedHeaders.has(slice.offset) && !partialHeaders.has(slice.offset)) + continue; + const headerLength = Math.min(12, slice.size); + const headerEnd = slice.offset + headerLength; + if (!Number.isSafeInteger(headerEnd)) continue; + const start = Math.max(slice.offset, streamOffset); + const end = Math.min(headerEnd, streamOffset + bytes.length); + if (start >= end) continue; + let pending = partialHeaders.get(slice.offset); + if (pending === undefined) { + pending = { bytes: Buffer.alloc(12), count: 0 }; + partialHeaders.set(slice.offset, pending); + } + Buffer.from(bytes.subarray(start - streamOffset, end - streamOffset)).copy( + pending.bytes, + start - slice.offset, + ); + pending.count += end - start; + observedHeaders.set( + slice.offset, + readFatSliceHeaderFacts( + pending.bytes.subarray(0, Math.min(pending.count, headerLength)), + ), + ); + if (pending.count === headerLength) { + partialHeaders.delete(slice.offset); + } + } +}; + +const finishClassificationEvidence = ( + prefix: Buffer, + archiveBytes: number, + finishAsar: (archiveBytes: number) => FinalAsarHeaderProbeObservation, + sliceHeadersByOffset: Map, +): ArtifactClassificationEvidence => { + const finalHeader = readFatContainerHeader(prefix, archiveBytes); + if (finalHeader.state === "parsed") { + for (let index = 0; index < finalHeader.slices.length; index += 1) { + const slice = finalHeader.slices[index]; + if (slice === undefined || sliceHeadersByOffset.has(slice.offset)) + continue; + const available = Math.max( + 0, + Math.min(slice.size, prefix.length - slice.offset), + ); + if (available === 0) continue; + sliceHeadersByOffset.set( + slice.offset, + readFatSliceHeaderFacts( + prefix.subarray(slice.offset, slice.offset + available), + ), + ); + } + } + const fatSliceHeaders = + finalHeader.state === "parsed" + ? { header: finalHeader, sliceHeadersByOffset } + : undefined; + return { + prefix, + asarHeader: finishAsar(archiveBytes), + ...(fatSliceHeaders === undefined ? {} : { fatSliceHeaders }), + }; +}; + /** Share acquisition bounds and signature observations across files and streams. */ export const createArtifactClassificationProbe = () => { const xml = createXmlPlistSignatureProbe(); @@ -23,6 +111,12 @@ export const createArtifactClassificationProbe = () => { const blocks: Buffer[] = []; let retainedBytes = 0; let observedBytes = 0; + let fatTable: FatContainerTable | undefined; + const fatSliceHeadersByOffset = new Map(); + const partialFatMagics = new Map< + number, + { readonly bytes: Buffer; count: number } + >(); const retain = (bytes: Uint8Array): void => { let offset = 0; while (offset < bytes.length) { @@ -41,6 +135,16 @@ export const createArtifactClassificationProbe = () => { offset += count; } }; + const retainedPrefix = (): Buffer => { + const prefix = Buffer.allocUnsafe(retainedBytes); + let copied = 0; + for (const block of blocks) { + const count = Math.min(block.length, retainedBytes - copied); + prefix.set(block.subarray(0, count), copied); + copied += count; + } + return prefix; + }; return { get length(): number { return observedBytes; @@ -55,6 +159,7 @@ export const createArtifactClassificationProbe = () => { ); }, write(bytes: Uint8Array): void { + const streamOffset = observedBytes; asar.write(bytes); let offset = 0; while (offset < bytes.length) { @@ -73,17 +178,27 @@ export const createArtifactClassificationProbe = () => { xml.write(selected); offset += selected.length; } + if ( + (fatTable === undefined || fatTable.state === "needs-data") && + retainedBytes >= 8 + ) + fatTable = readFatContainerTable(retainedPrefix()); + observeFatSliceHeaders({ + table: fatTable, + observedHeaders: fatSliceHeadersByOffset, + partialHeaders: partialFatMagics, + streamOffset, + bytes, + }); observedBytes += bytes.length; }, finish(archiveBytes: number): ArtifactClassificationEvidence { - const prefix = Buffer.allocUnsafe(retainedBytes); - let copied = 0; - for (const block of blocks) { - const count = Math.min(block.length, retainedBytes - copied); - prefix.set(block.subarray(0, count), copied); - copied += count; - } - return { prefix, asarHeader: asar.finish(archiveBytes) }; + return finishClassificationEvidence( + retainedPrefix(), + archiveBytes, + (bytes) => asar.finish(bytes), + fatSliceHeadersByOffset, + ); }, }; }; diff --git a/src/artifacts/apple/MachoImage.fixture.ts b/src/artifacts/apple/MachoImage.fixture.ts index 07fe1d3164..e965da22ae 100644 --- a/src/artifacts/apple/MachoImage.fixture.ts +++ b/src/artifacts/apple/MachoImage.fixture.ts @@ -188,6 +188,25 @@ export const fatImage = ( return bytes; }; +/** A minimal BSD ar archive containing one inert member for container tests. */ +export const arArchive = (memberName = "fixture.o"): Uint8Array => { + const name = `${memberName}/`.slice(0, 16).padEnd(16, " "); + const header = [ + name, + "0 ", + "0 ", + "0 ", + "100644 ", + "1 ", + "`\n", + ].join(""); + const bytes = new Uint8Array(8 + 60 + 2); + bytes.set(encoder.encode("!\n"), 0); + bytes.set(encoder.encode(header), 8); + bytes[68] = 0x78; + return bytes; +}; + /** In-memory `ReadAt` over one buffer. */ export const readerOf = (bytes: Uint8Array) => diff --git a/src/artifacts/apple/machoContainer.test.ts b/src/artifacts/apple/machoContainer.test.ts new file mode 100644 index 0000000000..83820dab18 --- /dev/null +++ b/src/artifacts/apple/machoContainer.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vitest"; + +import { + CPU, + arArchive, + fatImage, + machoImage, + readerOf, +} from "./MachoImage.fixture.js"; +import { readMachoImage } from "./MachoLoadCommandReader.js"; +import { javaClassFile } from "../../../tests/fixtures/javaClassFile.js"; +import { parseExecutableHeader } from "../../domain/binaryTarget.js"; +import { + classifyFatContainerIdentity, + readFatContainerHeader, + readFatSliceHeaderFacts, +} from "../../domain/apple/machoContainer.js"; + +const classify = (bytes: Uint8Array) => { + const header = readFatContainerHeader(bytes, bytes.byteLength); + if (header.state !== "parsed") return header; + return classifyFatContainerIdentity(header, (offset) => { + if (offset + 4 > bytes.byteLength) return undefined; + return new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint32(offset, false); + }); +}; + +describe("FAT Mach-O identity", () => { + it("does not treat known CPU declarations as proof when slices are ar archives", async () => { + const archive = arArchive(); + const bytes = fatImage([ + { cpu: CPU.x86_64, bytes: archive }, + { cpu: CPU.arm64, bytes: archive }, + ]); + + expect(classify(bytes)).toEqual({ state: "not-mach-o" }); + expect(await readMachoImage(readerOf(bytes), bytes.byteLength)).toEqual({ + status: "not-mach-o", + }); + expect(parseExecutableHeader(Buffer.from(bytes), "arm64")).toEqual({ + ok: false, + error: "FAT table does not identify a Mach-O image", + }); + }); + + it("rejects a mixed FAT container instead of selecting an ar slice as Mach-O", async () => { + const bytes = fatImage([ + { + cpu: CPU.x86_64, + bytes: machoImage({ cpu: CPU.x86_64 }), + }, + { cpu: CPU.arm64, bytes: arArchive() }, + ]); + + expect(classify(bytes)).toEqual({ state: "not-mach-o" }); + expect(await readMachoImage(readerOf(bytes), bytes.byteLength)).toEqual({ + status: "not-mach-o", + }); + expect(parseExecutableHeader(Buffer.from(bytes), "arm64")).toEqual({ + ok: false, + error: "FAT table does not identify a Mach-O image", + }); + }); + + it("confirms universal and thin Mach-O controls from their image headers", async () => { + const slice = machoImage({ cpu: CPU.arm64 }); + const universal = fatImage([{ cpu: CPU.arm64, bytes: slice }]); + expect(classify(universal)).toMatchObject({ state: "mach-o" }); + expect( + await readMachoImage(readerOf(universal), universal.byteLength), + ).toMatchObject({ + status: "parsed", + }); + expect( + parseExecutableHeader(Buffer.from(universal), "arm64"), + ).toMatchObject({ + ok: true, + value: { format: "mach-o-universal", architecture: "arm64" }, + }); + + expect( + await readMachoImage(readerOf(slice), slice.byteLength), + ).toMatchObject({ + status: "parsed", + }); + expect(parseExecutableHeader(Buffer.from(slice), "arm64")).toMatchObject({ + ok: true, + value: { format: "mach-o", architecture: "arm64" }, + }); + + const undeclaredCpu = { type: 0x12345678, subtype: 0 }; + const unlistedCpuImage = fatImage([ + { cpu: undeclaredCpu, bytes: machoImage({ cpu: undeclaredCpu }) }, + ]); + expect(classify(unlistedCpuImage)).toMatchObject({ state: "mach-o" }); + }); + + it("parses CPU identity from the actual thin-header byte order", () => { + const littleEndian = machoImage({ cpu: CPU.arm64 }); + const bigEndian = Buffer.alloc(12); + bigEndian.writeUInt32BE(0xfeedfacf, 0); + bigEndian.writeUInt32BE(CPU.x86_64.type, 4); + bigEndian.writeUInt32BE(CPU.x86_64.subtype, 8); + expect(readFatSliceHeaderFacts(littleEndian.subarray(0, 12))).toEqual({ + magic: 0xcffaedfe, + identity: { + cpuType: CPU.arm64.type, + cpuSubtype: CPU.arm64.subtype, + }, + }); + expect(readFatSliceHeaderFacts(bigEndian)).toEqual({ + magic: 0xfeedfacf, + identity: { + cpuType: CPU.x86_64.type, + cpuSubtype: CPU.x86_64.subtype, + }, + }); + expect(readFatSliceHeaderFacts(littleEndian.subarray(0, 11))).toEqual({ + magic: 0xcffaedfe, + identity: null, + }); + expect(readFatSliceHeaderFacts(littleEndian.subarray(0, 3))).toEqual({ + magic: null, + identity: null, + }); + }); + + it.each([ + { wrongIndex: 0, label: "selected host slice" }, + { wrongIndex: 1, label: "non-host slice" }, + ])( + "rejects table CPU claims that disagree with the $label header", + ({ wrongIndex }) => { + const bytes = fatImage([ + { cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) }, + { cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }, + ]); + const entry = 8 + wrongIndex * 20; + // Change only the FAT claim; both embedded thin headers remain intact. + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32( + entry, + wrongIndex === 0 ? CPU.arm64.type : CPU.x86_64.type, + false, + ); + const result = parseExecutableHeader(Buffer.from(bytes), "arm64"); + expect(result).toEqual({ + ok: false, + error: `FAT architecture ${wrongIndex} CPU type/subtype disagrees with its Mach-O header`, + }); + }, + ); +}); + +describe("FAT slice header identity and bounds", () => { + it("keeps Java CAFEBABE class files outside FAT and Mach-O identity", async () => { + const bytes = javaClassFile(); + expect(readFatContainerHeader(bytes, bytes.byteLength)).toEqual({ + state: "not-fat", + }); + expect(await readMachoImage(readerOf(bytes), bytes.byteLength)).toEqual({ + status: "not-mach-o", + }); + expect(parseExecutableHeader(Buffer.from(bytes), "arm64")).toEqual({ + ok: false, + error: "unsupported binary format", + }); + }); + + it("uses the shared exact subtype identity rule, including capability bits", () => { + const bytes = fatImage([ + { cpu: CPU.arm64e, bytes: machoImage({ cpu: CPU.arm64e }) }, + ]); + // The FAT table drops the subtype capability bits while the thin header + // retains them. Admission follows validateFatSliceIdentity's exact rule. + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32( + 12, + CPU.arm64e.subtype & 0x00ffffff, + false, + ); + expect(parseExecutableHeader(Buffer.from(bytes), "arm64")).toEqual({ + ok: false, + error: + "FAT architecture 0 CPU type/subtype disagrees with its Mach-O header", + }); + }); + + it("keeps unresolved headers unknown and preserves declared-range failures", () => { + const bytes = Buffer.alloc(28); + bytes.writeUInt32BE(0xcafebabe, 0); + bytes.writeUInt32BE(1, 4); + bytes.writeUInt32BE(CPU.arm64.type, 8); + bytes.writeUInt32BE(4096, 16); + bytes.writeUInt32BE(32, 20); + bytes.writeUInt32BE(12, 24); + const header = readFatContainerHeader(bytes, 4128); + if (header.state !== "parsed") throw new Error("expected parsed FAT table"); + expect(classifyFatContainerIdentity(header, () => undefined)).toEqual({ + state: "needs-slice-header", + offsets: [4096], + }); + + const badRange = { + ...header, + rangeIssues: [ + { category: "malformed" as const, reason: "bad test range" }, + ], + }; + expect(classifyFatContainerIdentity(badRange, () => null)).toEqual({ + state: "malformed", + category: "malformed", + reason: "bad test range", + }); + }); + + it("classifies a valid but too-short slice without inventing header bytes", () => { + const bytes = Buffer.alloc(31); + bytes.writeUInt32BE(0xcafebabe, 0); + bytes.writeUInt32BE(1, 4); + bytes.writeUInt32BE(CPU.arm64.type, 8); + bytes.writeUInt32BE(28, 16); + bytes.writeUInt32BE(3, 20); + bytes.set([0xcf, 0xfa, 0xed], 28); + const header = readFatContainerHeader(bytes, bytes.length); + if (header.state !== "parsed") throw new Error("expected parsed FAT table"); + expect( + classifyFatContainerIdentity(header, () => { + throw new Error("a three-byte slice cannot provide a four-byte magic"); + }), + ).toEqual({ state: "not-mach-o" }); + }); +}); diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts index 9031edb8e4..7432e7751c 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts @@ -1,12 +1,19 @@ import { describe, expect, it } from "vitest"; -import { CPU, fatImage, machoImage } from "../apple/MachoImage.fixture.js"; +import { + CPU, + arArchive, + fatImage, + machoImage, +} from "../apple/MachoImage.fixture.js"; import { createArtifactEdges, + classifyArtifactContent, createOccurrence, indexOccurrencesByPath, resolveOccurrenceParents, } from "./ArtifactGraphConstruction.js"; +import { createArtifactClassificationProbe } from "../ArtifactClassificationProbe.js"; import { artifactIdForContent } from "../../domain/artifactIdentity.js"; import type { ArtifactEntry } from "../ArtifactReader.js"; @@ -164,8 +171,49 @@ describe("source-map artifact relationships", () => { const fat = (wide = false): Buffer => Buffer.from(fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }], wide)); +const expectStreamedFatClassification = (testCase: { + readonly slices: readonly { + readonly cpu: { readonly type: number; readonly subtype: number }; + readonly bytes: Uint8Array; + }[]; + readonly expected: string; +}): void => { + const bytes = Buffer.alloc(24_128); + const offsets = testCase.slices.map((_, index) => 20_000 + index * 4_000); + bytes.writeUInt32BE(0xcafebabe, 0); + bytes.writeUInt32BE(testCase.slices.length, 4); + testCase.slices.forEach((slice, index) => { + const entry = 8 + index * 20; + const sliceOffset = offsets[index]!; + bytes.writeUInt32BE(slice.cpu.type, entry); + bytes.writeUInt32BE(slice.cpu.subtype, entry + 4); + bytes.writeUInt32BE(sliceOffset, entry + 8); + bytes.writeUInt32BE(slice.bytes.length, entry + 12); + bytes.writeUInt32BE(0, entry + 16); + Buffer.from(slice.bytes).copy(bytes, sliceOffset); + }); + + const probe = createArtifactClassificationProbe(); + const boundaries = [ + 8_192, + ...offsets.flatMap((offset) => [offset + 3, offset + 4]), + bytes.length, + ].sort((left, right) => left - right); + let previous = 0; + for (const boundary of boundaries) { + if (boundary <= previous) continue; + probe.write(bytes.subarray(previous, boundary)); + previous = boundary; + } + const evidence = probe.finish(bytes.length); + expect(evidence.prefix.length).toBeLessThan(bytes.length); + expect(classifyArtifactContent("native", evidence, bytes.length).format).toBe( + testCase.expected, + ); +}; + describe("bounded FAT inventory classification", () => { - it("requires the complete table but not slice bytes in the retained prefix", () => { + it("requires the complete table and an observed slice header", () => { const bytes = fat(); for (const length of [4, 7, 8, 27]) { expect( @@ -182,7 +230,25 @@ describe("bounded FAT inventory classification", () => { bytes.subarray(0, 28), bytes.length, ), - ).toEqual({ kind: "executable", format: "mach-o-universal" }); + ).toEqual({ kind: "unknown", format: "unknown" }); + expect(classifyArtifactBytesAtPath("native.class", bytes)).toEqual({ + kind: "executable", + format: "mach-o-universal", + }); + }); + + it("does not classify known-CPU FAT archive slices as Mach-O", () => { + const archive = arArchive(); + const bytes = Buffer.from( + fatImage([ + { cpu: CPU.x86_64, bytes: archive }, + { cpu: CPU.arm64, bytes: archive }, + ]), + ); + expect(classifyArtifactBytesAtPath("libfat.a", bytes)).toEqual({ + kind: "unknown", + format: "unknown", + }); }); it.each([ @@ -252,6 +318,65 @@ describe("bounded FAT inventory classification", () => { }); }); + it("classifies distant FAT headers from bounded streaming observations", () => { + const cases = [ + { + name: "Mach-O", + slices: [{ cpu: CPU.arm64, bytes: machoImage({}) }], + expected: "mach-o-universal", + }, + { + name: "archive", + slices: [{ cpu: CPU.arm64, bytes: arArchive() }], + expected: "unknown", + }, + { + name: "mixed Mach-O and archive", + slices: [ + { cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) }, + { cpu: CPU.arm64, bytes: arArchive() }, + ], + expected: "unknown", + }, + ] as const; + for (const testCase of cases) expectStreamedFatClassification(testCase); + }); +}); + +describe("FAT prefix observation coverage", () => { + it("does not invent an observation for a header beyond the retained prefix", () => { + const bytes = Buffer.alloc(8_224); + bytes.writeUInt32BE(0xcafebabe, 0); + bytes.writeUInt32BE(2, 4); + for (const [index, slice] of [ + { cpu: CPU.x86_64, offset: 4_096 }, + { cpu: CPU.arm64, offset: 8_192 }, + ].entries()) { + const entry = 8 + index * 20; + bytes.writeUInt32BE(slice.cpu.type, entry); + bytes.writeUInt32BE(slice.cpu.subtype, entry + 4); + bytes.writeUInt32BE(slice.offset, entry + 8); + bytes.writeUInt32BE(32, entry + 12); + bytes.writeUInt32BE(12, entry + 16); + bytes.writeUInt32LE(0xfeedfacf, slice.offset); + bytes.writeUInt32LE(slice.cpu.type, slice.offset + 4); + bytes.writeUInt32LE(slice.cpu.subtype, slice.offset + 8); + } + + const probe = createArtifactClassificationProbe(); + probe.write(bytes.subarray(0, 8_192)); + const evidence = probe.finish(bytes.length); + + expect( + evidence.fatSliceHeaders?.sliceHeadersByOffset.get(4_096)?.magic, + ).toBe(0xcffaedfe); + expect(evidence.fatSliceHeaders?.sliceHeadersByOffset.has(8_192)).toBe( + false, + ); + }); +}); + +describe("bounded FAT inventory classification", () => { it.each([NaN, Infinity, -1, 8.5, Number.MAX_SAFE_INTEGER + 1, 27])( "keeps invalid or contradictory file size %s unknown", (size) => { diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 38bc04ca9a..38de1a2be7 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -33,6 +33,7 @@ import { import { classifyFatContainerIdentity, readFatContainerHeader, + readFatSliceHeaderFacts, } from "../../domain/apple/machoContainer.js"; /** Mutable internal occurrence used until root-bound IDs are known. */ @@ -191,6 +192,7 @@ export const createRootNode = (input: { readonly sha256: string; readonly bytes: number; readonly prefix: Buffer; + readonly fatSliceHeaders?: ArtifactClassificationEvidence["fatSliceHeaders"]; } | null; readonly occurrences: readonly MutableOccurrence[]; }): ArtifactNode => @@ -215,7 +217,11 @@ export const createRootNode = (input: { format: input.digest === null ? "directory" - : classifyArtifactBytes(input.digest.prefix, input.digest.bytes), + : classifyArtifactBytes( + input.digest.prefix, + input.digest.bytes, + input.digest.fatSliceHeaders, + ), contentState: "materialized", }); @@ -498,8 +504,8 @@ export const classifyArtifactContent = ( readonly kind: ArtifactOccurrence["artifact_kind"]; readonly format: ArtifactOccurrence["artifact_format"]; } => { - const { prefix, asarHeader } = evidence; - const format = classifyArtifactBytes(prefix, fileSize); + const { prefix, asarHeader, fatSliceHeaders } = evidence; + const format = classifyArtifactBytes(prefix, fileSize, fatSliceHeaders); const byPath = classifyArtifactPath(path); if ( format === "plist" && @@ -550,12 +556,13 @@ const artifactRoleForFormat = ( export const classifyArtifactBytes = ( prefix: Buffer, fileSize = prefix.length, + fatSliceHeaders?: ArtifactClassificationEvidence["fatSliceHeaders"], ): ArtifactNode["format"] => { if (hasZipSignature(prefix)) return "zip"; if (hasGodotPckHeader(prefix)) return "godot-pck"; if (hasBinaryPlistSignature(prefix)) return "plist"; if (hasXmlPlistSignature(prefix)) return "plist"; - const fatFormat = classifyFatArtifact(prefix, fileSize); + const fatFormat = classifyFatArtifact(prefix, fileSize, fatSliceHeaders); if (fatFormat !== null) return fatFormat; if (prefix.length >= 4) { const magic = prefix.readUInt32BE(0); @@ -592,15 +599,26 @@ export const classifyArtifactBytes = ( const classifyFatArtifact = ( prefix: Buffer, fileSize: number, + fatSliceHeaders?: ArtifactClassificationEvidence["fatSliceHeaders"], ): ArtifactNode["format"] | null => { - const fatHeader = readFatContainerHeader(prefix, fileSize); + const fatHeader = + fatSliceHeaders?.header ?? readFatContainerHeader(prefix, fileSize); if (fatHeader.state === "needs-data" || fatHeader.state === "malformed") return "unknown"; if (fatHeader.state === "not-fat") return null; const identity = classifyFatContainerIdentity(fatHeader, (offset) => { - if (offset >= prefix.length) return undefined; - if (offset + 4 > prefix.length) return null; - return prefix.readUInt32BE(offset); + if (fatSliceHeaders?.sliceHeadersByOffset.has(offset) === true) + return fatSliceHeaders.sliceHeadersByOffset.get(offset)?.magic; + const slice = fatHeader.slices.find( + (candidate) => candidate.offset === offset, + ); + if (slice === undefined || offset >= prefix.length) return undefined; + const available = Math.max( + 0, + Math.min(slice.size, prefix.length - offset, 12), + ); + return readFatSliceHeaderFacts(prefix.subarray(offset, offset + available)) + .magic; }); return identity.state === "mach-o" ? "mach-o-universal" : "unknown"; }; diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index aa72fd9efe..104fe03329 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -23,6 +23,8 @@ import { openRootArtifact, } from "./hashStableRootArtifact.js"; import type { ArtifactInventoryOptions } from "./types.js"; +import type { FatSliceHeaderEvidence } from "../../domain/apple/machoContainer.js"; +import { readFatSliceHeaderFacts } from "../../domain/apple/machoContainer.js"; interface RootClassification { readonly format: ArtifactOccurrence["artifact_format"]; @@ -171,7 +173,62 @@ const classifyRootFormat = async ( return "dmg"; if (await locateGodotPck(handle, archiveBytes, signal, evidence.prefix)) return "godot-pck"; - return classifyArtifactContent(path, evidence, archiveBytes).format; + const fatSliceHeaders = await readRootFatSliceHeaders( + handle, + evidence.fatSliceHeaders, + signal, + ); + return classifyArtifactContent( + path, + { + ...evidence, + ...(fatSliceHeaders === undefined ? {} : { fatSliceHeaders }), + }, + archiveBytes, + ).format; +}; + +/** Read exact FAT header windows missed by the bounded prefix probe. */ +const readRootFatSliceHeaders = async ( + handle: FileHandle, + existing: FatSliceHeaderEvidence | undefined, + signal?: AbortSignal, +): Promise => { + if (existing === undefined) return undefined; + const { header } = existing; + const sliceHeadersByOffset = new Map(existing.sliceHeadersByOffset); + for (let index = 0; index < header.slices.length; index += 1) { + const slice = header.slices[index]; + const observed = + slice === undefined ? undefined : sliceHeadersByOffset.get(slice.offset); + if ( + slice === undefined || + header.rangeIssues[index] !== null || + slice.size < 4 || + (observed !== undefined && observed.magic !== null) + ) + continue; + const headerLength = Math.min(12, slice.size); + const thinHeader = Buffer.alloc(headerLength); + let bytesRead = 0; + while (bytesRead < thinHeader.length) { + abortIfNeeded(signal); + const read = await handle.read( + thinHeader, + bytesRead, + thinHeader.length - bytesRead, + slice.offset + bytesRead, + ); + abortIfNeeded(signal); + if (read.bytesRead === 0) break; + bytesRead += read.bytesRead; + } + sliceHeadersByOffset.set( + slice.offset, + readFatSliceHeaderFacts(thinHeader.subarray(0, bytesRead)), + ); + } + return { header, sliceHeadersByOffset }; }; /** App Pack is a verified ZIP root role; nested .app members retain their observed format. */ diff --git a/src/domain/apple/machoContainer.ts b/src/domain/apple/machoContainer.ts index 7d5ae9f611..f6ed4648ac 100644 --- a/src/domain/apple/machoContainer.ts +++ b/src/domain/apple/machoContainer.ts @@ -34,6 +34,57 @@ export type FatContainerHeader = readonly rangeIssues: readonly (FatSliceRangeIssue | null)[]; }; +/** Exact slice-header observations collected by a complete, bounded reader. */ +export interface FatSliceHeaderEvidence { + readonly header: Extract; + readonly sliceHeadersByOffset: ReadonlyMap; +} + +/** Parsed observations from the bounded prefix of one declared slice header. */ +export interface FatSliceHeaderFacts { + /** Raw format evidence; null means fewer than four bytes were observed. */ + readonly magic: number | null; + /** Present only for a recognized Mach-O magic and a complete 12-byte prefix. */ + readonly identity: { + readonly cpuType: number; + readonly cpuSubtype: number; + } | null; +} + +/** Parse only facts present in the observed prefix of a thin Mach-O header. */ +export const readFatSliceHeaderFacts = ( + bytes: Uint8Array, +): FatSliceHeaderFacts => { + if (bytes.byteLength < 4) return { magic: null, identity: null }; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const magic = view.getUint32(0, false); + if (bytes.byteLength < 12 || !MACHO_HEADER_MAGICS.includes(magic)) + return { magic, identity: null }; + const littleEndian = magic === 0xcefaedfe || magic === 0xcffaedfe; + return { + magic, + identity: { + cpuType: view.getUint32(4, littleEndian), + cpuSubtype: view.getUint32(8, littleEndian), + }, + }; +}; + +/** FAT declarations decoded before the caller knows the final stream length. */ +export type FatContainerTable = + | { readonly state: "not-fat" } + | { readonly state: "needs-data"; readonly requiredBytes: number } + | { readonly state: "malformed"; readonly reason: string } + | { + readonly state: "parsed"; + readonly magic: number; + readonly count: number; + readonly wide: boolean; + readonly littleEndian: boolean; + readonly tableEnd: number; + readonly slices: readonly FatSliceDeclaration[]; + }; + export type FatContainerIdentity = | { readonly state: "mach-o" } | { readonly state: "not-mach-o" } @@ -64,22 +115,39 @@ export const readFatContainerHeader = ( bytes: Uint8Array, fileSize: number, ): FatContainerHeader => { - if (bytes.byteLength < 4) return { state: "not-fat" }; - 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" }; + if (!hasFatMagic(bytes)) 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); + if (table.state !== "parsed") { + if (table.state === "needs-data" && table.requiredBytes > fileSize) { + return table.requiredBytes === 8 && fileSize < 8 + ? { state: "malformed", reason: "FAT header is truncated" } + : { state: "not-fat" }; + } + return table; + } if (bytes.byteLength === fileSize && isJvmClassFile(bytes)) return { state: "not-fat" }; + if (table.tableEnd > fileSize) return { state: "not-fat" }; + return { + ...table, + rangeIssues: table.slices.map((slice) => + inspectFatSliceRange(slice, fileSize, table.tableEnd), + ), + }; +}; + +/** 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 fileSize < 8 - ? { state: "malformed", reason: "FAT header is truncated" } - : { state: "needs-data", requiredBytes: 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; @@ -91,7 +159,6 @@ export const readFatContainerHeader = ( }; if (count > MAX_FAT_ARCHITECTURES) return { state: "not-fat" }; const tableEnd = 8 + count * (wide ? 32 : 20); - if (tableEnd > fileSize) return { state: "not-fat" }; if (tableEnd > bytes.byteLength) return { state: "needs-data", requiredBytes: tableEnd }; const declarations = readFatSliceDeclarations(bytes.subarray(8, tableEnd), { @@ -108,14 +175,19 @@ export const readFatContainerHeader = ( littleEndian, tableEnd, slices: declarations.slices, - rangeIssues: declarations.slices.map((slice) => - inspectFatSliceRange(slice, fileSize, tableEnd), - ), } : { state: "malformed", reason: declarations.reason }; }; -/** Confirm FAT identity from architecture records and observed slice headers. */ +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, sliceHeaderAt: (offset: number) => number | null | undefined, @@ -126,28 +198,34 @@ export const classifyFatContainerIdentity = ( const invalidRange = header.rangeIssues.find( (issue): issue is FatSliceRangeIssue => issue !== null, ); - if (hasKnownCpu) - return invalidRange === undefined - ? { state: "mach-o" } - : { state: "malformed", ...invalidRange }; + // Preserve the existing malformed-range result for recognized CPU tables; + // a valid CPU declaration by itself does not establish a Mach-O image. + if (hasKnownCpu && invalidRange !== undefined) + return { state: "malformed", ...invalidRange }; const validRanges = header.slices.filter( (_slice, index) => header.rangeIssues[index] === null, ); const missingHeaders: number[] = []; let observedMachO = false; + let observedNonMachO = false; for (const slice of validRanges) { + if (slice.size < 4) { + observedNonMachO = true; + continue; + } const magic = sliceHeaderAt(slice.offset); - if (magic === undefined) missingHeaders.push(slice.offset); - else if (magic !== null && MACHO_HEADER_MAGICS.includes(magic)) - observedMachO = true; + if (magic === undefined || magic === null) + missingHeaders.push(slice.offset); + else if (MACHO_HEADER_MAGICS.includes(magic)) observedMachO = true; + else observedNonMachO = true; } - if (observedMachO) { + if (observedNonMachO) return { state: "not-mach-o" }; + if (missingHeaders.length > 0) + return { state: "needs-slice-header", offsets: missingHeaders }; + if (observedMachO) return invalidRange === undefined ? { state: "mach-o" } : { state: "malformed", ...invalidRange }; - } - if (missingHeaders.length > 0) - return { state: "needs-slice-header", offsets: missingHeaders }; return { state: "not-mach-o" }; }; diff --git a/src/domain/binaryTarget.test.ts b/src/domain/binaryTarget.test.ts index 33cce15f3f..e244e9cd0a 100644 --- a/src/domain/binaryTarget.test.ts +++ b/src/domain/binaryTarget.test.ts @@ -192,7 +192,9 @@ describe("executable header completeness", () => { expect(reason(bytes, 4100)).toBe( "FAT architecture 1 slice range extends beyond the file", ); - expect(reason(bytes, 4160)).toBeNull(); + expect(reason(bytes, 4160)).toBe( + "FAT slice identity is outside the bounded prefix", + ); expect(parseExecutableHeader(bytes, "x64", 4100)).toMatchObject({ ok: false, error: "FAT architecture 1 slice range extends beyond the file", diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 08c14b4211..ec0a89afdb 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -19,6 +19,9 @@ import { import { classifyFatContainerIdentity, readFatContainerHeader, + readFatSliceHeaderFacts, + validateFatSliceIdentity, + type FatSliceHeaderEvidence, } from "./apple/machoContainer.js"; /** Format and architecture facts recovered from an executable header. */ @@ -81,6 +84,7 @@ export const parseExecutableHeader = ( hostArchitecture: NodeJS.Architecture, fileSize = bytes.length, mzWindowsHeaderSignature?: Buffer, + fatEvidence?: FatSliceHeaderEvidence, ): Result => { if ( bytes.length >= 4 && @@ -93,9 +97,10 @@ export const parseExecutableHeader = ( const magic = bytes.readUInt32BE(0); if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) return parseThinMachO(bytes, magic, fileSize); - const fatHeader = readFatContainerHeader(bytes, fileSize); + const fatHeader = + fatEvidence?.header ?? readFatContainerHeader(bytes, fileSize); if (fatHeader.state !== "not-fat") - return parseFatMachO(bytes, hostArchitecture, fatHeader); + return parseFatMachO(bytes, hostArchitecture, fatHeader, fatEvidence); return err("unsupported binary format"); }; @@ -134,12 +139,23 @@ const parseFatMachO = ( ReturnType, { state: "not-fat" } >, + fatEvidence?: FatSliceHeaderEvidence, ): Result => { if (header.state === "needs-data") return err("truncated or invalid FAT architecture table"); if (header.state === "malformed") return err(header.reason); - const identityError = fatIdentityError(bytes, header); + const identityError = fatIdentityError( + bytes, + header, + fatEvidence?.sliceHeadersByOffset, + ); if (identityError !== null) return err(identityError); + const sliceIdentityError = fatSliceIdentityError( + bytes, + header, + fatEvidence?.sliceHeadersByOffset, + ); + if (sliceIdentityError !== null) return err(sliceIdentityError); const architectures: HeaderArchitecture[] = []; const slices = new Map< HeaderArchitecture, @@ -171,6 +187,41 @@ const parseFatMachO = ( }); }; +const fatSliceIdentityError = ( + bytes: Buffer, + header: Extract< + ReturnType, + { state: "parsed" } + >, + sliceHeadersByOffset?: ReadonlyMap< + number, + ReturnType + >, +): string | null => { + for (let index = 0; index < header.slices.length; index += 1) { + const declaration = header.slices[index]; + if (declaration === undefined || header.rangeIssues[index] !== null) + continue; + let facts: ReturnType | undefined; + if (sliceHeadersByOffset?.has(declaration.offset)) { + facts = sliceHeadersByOffset.get(declaration.offset); + } else if ( + declaration.size >= 12 && + declaration.offset <= bytes.length - 12 + ) { + facts = readFatSliceHeaderFacts( + bytes.subarray(declaration.offset, declaration.offset + 12), + ); + } + const identity = facts?.identity; + if (identity === null || identity === undefined) + return `FAT architecture ${declaration.index} thin Mach-O CPU identity is outside the bounded header`; + const identityError = validateFatSliceIdentity(declaration, identity); + if (identityError !== null) return identityError; + } + return null; +}; + const fatHostArchitecture = ( host: NodeJS.Architecture, ): HeaderArchitecture | undefined => { @@ -194,11 +245,24 @@ const fatIdentityError = ( ReturnType, { state: "parsed" } >, + sliceHeadersByOffset?: ReadonlyMap< + number, + ReturnType + >, ): string | null => { const identity = classifyFatContainerIdentity(header, (offset) => { - if (offset >= bytes.length) return undefined; - if (offset + 4 > bytes.length) return null; - return bytes.readUInt32BE(offset); + if (sliceHeadersByOffset?.has(offset)) + return sliceHeadersByOffset.get(offset)?.magic; + const slice = header.slices.find( + (candidate) => candidate.offset === offset, + ); + if (slice === undefined || offset >= bytes.length) return undefined; + const available = Math.max( + 0, + Math.min(slice.size, bytes.length - offset, 12), + ); + return readFatSliceHeaderFacts(bytes.subarray(offset, offset + available)) + .magic; }); if (identity.state === "needs-slice-header") return "FAT slice identity is outside the bounded prefix"; diff --git a/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts b/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts index 8d58f93d56..b2f6f56697 100644 --- a/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts +++ b/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts @@ -81,6 +81,34 @@ it("keeps unavailable container traversal distinct from observed root bytes", as }); }); +it("admits a FAT root when a valid slice header starts at the prefix boundary", async () => { + const root = await createTestTempDirectory("rea-fat-root-header-boundary-"); + const path = join(root, "universal"); + const bytes = Buffer.alloc(8_224); + bytes.writeUInt32BE(0xcafebabe, 0); + bytes.writeUInt32BE(2, 4); + const slices = [ + { cpu: 0x01000007, subtype: 3, offset: 4_096 }, + { cpu: 0x0100000c, subtype: 0, offset: 8_192 }, + ]; + for (const [index, slice] of slices.entries()) { + const entry = 8 + index * 20; + bytes.writeUInt32BE(slice.cpu, entry); + bytes.writeUInt32BE(slice.subtype, entry + 4); + bytes.writeUInt32BE(slice.offset, entry + 8); + bytes.writeUInt32BE(32, entry + 12); + bytes.writeUInt32BE(12, entry + 16); + bytes.writeUInt32LE(0xfeedfacf, slice.offset); + bytes.writeUInt32LE(slice.cpu, slice.offset + 4); + bytes.writeUInt32LE(slice.subtype, slice.offset + 8); + bytes.writeUInt32LE(2, slice.offset + 12); + } + await writeFile(path, bytes); + + const classified = await classifyAndHashRoot(path, false, await lstat(path)); + expect(classified.format).toBe("mach-o-universal"); +}); + it("recognizes a root ASAR when its exact JSON extent has legal surrounding whitespace", async () => { const root = await createTestTempDirectory("rea-asar-root-whitespace-"); const path = join(root, "app.asar"); diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 3a8263c5f1..e05483baff 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { + open, chmod, mkdir, readFile, @@ -30,6 +31,12 @@ import { hasXmlPlistSignature } from "../../../src/domain/apple/appleArchiveForm import { parseExecutableHeader } from "../../../src/domain/binaryTarget.js"; import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + arArchive, + CPU, + fatImage, + machoImage, +} from "../../../src/artifacts/apple/MachoImage.fixture.js"; it.each([0x01c0, 0x01c2, 0x01c4, 0xaa64])( "retains original PE COFF machine %i through file-backed target resolution", @@ -84,6 +91,127 @@ it.each([{ cpus: [0x0100000c] }, { cpus: [0x01000007, 0x0100000c] }])( }, ); +it("reads a sparse FAT slice header beyond the bounded prefix", async () => { + const directory = await createTestTempDirectory("rea-sparse-fat-target-"); + const path = join(directory, "image"); + const sliceOffset = 64 * 1024; + const file = await open(path, "w+"); + try { + const fatHeader = Buffer.alloc(28); + fatHeader.writeUInt32BE(0xcafebabe, 0); + fatHeader.writeUInt32BE(1, 4); + fatHeader.writeUInt32BE(CPU.arm64.type, 8); + fatHeader.writeUInt32BE(CPU.arm64.subtype, 12); + fatHeader.writeUInt32BE(sliceOffset, 16); + fatHeader.writeUInt32BE(32, 20); + await file.write(fatHeader, 0, fatHeader.length, 0); + const sliceHeader = machoImage({ cpu: CPU.arm64 }).subarray(0, 12); + await file.write(sliceHeader, 0, sliceHeader.length, sliceOffset); + await file.truncate(sliceOffset + 32); + } finally { + await file.close(); + } + expect( + await parseBinaryTarget(path, { hostArchitecture: "arm64" }), + ).toMatchObject({ + ok: true, + value: { format: "mach-o-universal", architecture: "arm64" }, + }); +}); + +it("reads the complete bounded 128-entry FAT64 table beyond the prefix", async () => { + const directory = await createTestTempDirectory("rea-fat64-max-table-"); + const path = join(directory, "image"); + const entryCount = 128; + const tableEnd = 8 + entryCount * 32; + const sliceSize = 32; + const file = await open(path, "w+"); + try { + const table = Buffer.alloc(tableEnd); + table.writeUInt32BE(0xcafebabf, 0); + table.writeUInt32BE(entryCount, 4); + const thinHeader = machoImage({ cpu: CPU.arm64 }).subarray(0, 12); + for (let index = 0; index < entryCount; index += 1) { + const entry = 8 + index * 32; + const sliceOffset = tableEnd + index * sliceSize; + table.writeUInt32BE(CPU.arm64.type, entry); + table.writeUInt32BE(CPU.arm64.subtype, entry + 4); + table.writeBigUInt64BE(BigInt(sliceOffset), entry + 8); + table.writeBigUInt64BE(BigInt(sliceSize), entry + 16); + await file.write(thinHeader, 0, thinHeader.length, sliceOffset); + } + await file.write(table, 0, table.length, 0); + await file.truncate(tableEnd + entryCount * sliceSize); + } finally { + await file.close(); + } + expect( + await parseBinaryTarget(path, { hostArchitecture: "arm64" }), + ).toMatchObject({ + ok: true, + value: { format: "mach-o-universal", architecture: "arm64" }, + }); +}); + +it.each([0, 1])( + "rejects a file-backed FAT CPU mismatch in slice %i before host selection", + async (wrongIndex) => { + const directory = await createTestTempDirectory("rea-fat-cpu-mismatch-"); + const path = join(directory, "image"); + const bytes = fatImage([ + { cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) }, + { cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }, + ]); + const entry = 8 + wrongIndex * 20; + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32( + entry, + wrongIndex === 0 ? CPU.arm64.type : CPU.x86_64.type, + false, + ); + await writeFile(path, Buffer.from(bytes)); + expect( + await parseBinaryTarget(path, { hostArchitecture: "arm64" }), + ).toMatchObject({ + ok: false, + error: { + _tag: "BinaryTargetError", + reason: `FAT architecture ${wrongIndex} CPU type/subtype disagrees with its Mach-O header`, + constraint: "file_format_rejected", + }, + }); + }, +); + +it.each([ + { + name: "archive slices", + slices: [ + { cpu: CPU.x86_64, bytes: arArchive() }, + { cpu: CPU.arm64, bytes: arArchive("second.o") }, + ], + }, + { + name: "mixed Mach-O and archive slices", + slices: [ + { cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) }, + { cpu: CPU.arm64, bytes: arArchive() }, + ], + }, +])("refuses FAT $name before provider selection", async ({ slices }) => { + const directory = await createTestTempDirectory("rea-fat-archive-target-"); + const path = join(directory, "image"); + await writeFile(path, fatImage(slices)); + expect( + await parseBinaryTarget(path, { hostArchitecture: "arm64" }), + ).toMatchObject({ + ok: false, + error: { + _tag: "BinaryTargetError", + constraint: "file_format_rejected", + }, + }); +}); + describe("binary target I/O: app plist permissions and decoding", () => { it.each([ ["EACCES", "permission denied reading app Info.plist"],