From 44521d3d752a4638957e062dfc243a5ec0950fba Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:37:25 +0800 Subject: [PATCH] refactor(macho): share thin header CPU interpretation Give partial thin-header facts one domain owner and reuse them for standalone target admission and Mach-O load-command inspection. Preserve each consumer endianness policy and header/command checks; remove the FAT-only parser names. Refs #5542. All 246 focused checks and six public CLI archive/image controls passed; typecheck and lint passed. --- src/application/BinaryTargetResolver.ts | 10 +++++----- src/artifacts/ArtifactClassificationProbe.ts | 18 +++++++++--------- src/artifacts/apple/MachoLoadCommandReader.ts | 16 ++++++++++++++-- src/artifacts/apple/machoContainer.test.ts | 10 +++++----- .../inventory/ArtifactGraphConstruction.ts | 7 ++++--- src/artifacts/inventory/classify.ts | 4 ++-- src/domain/apple/machoContainer.ts | 10 +++++----- src/domain/binaryTarget.ts | 19 +++++++++++-------- 8 files changed, 55 insertions(+), 39 deletions(-) diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index ac8e9f454e..b2b6834e14 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -55,9 +55,9 @@ import { } from "../domain/binaryTarget.js"; import { readFatContainerHeader, - readFatSliceHeaderFacts, + readMachoHeaderPrefixFacts, type FatSliceHeaderEvidence, - type FatSliceHeaderFacts, + type MachoHeaderPrefixFacts, } from "../domain/apple/machoContainer.js"; import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; @@ -608,14 +608,14 @@ const readFatSliceHeaderEvidence = async ( } if (header.state !== "parsed") return undefined; - const sliceHeadersByOffset = new Map(); + 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)), + readMachoHeaderPrefixFacts(Buffer.alloc(0)), ); continue; } @@ -636,7 +636,7 @@ const readFatSliceHeaderEvidence = async ( } sliceHeadersByOffset.set( slice.offset, - readFatSliceHeaderFacts(thinHeader.subarray(0, bytesRead)), + readMachoHeaderPrefixFacts(thinHeader.subarray(0, bytesRead)), ); } return { header, sliceHeadersByOffset }; diff --git a/src/artifacts/ArtifactClassificationProbe.ts b/src/artifacts/ArtifactClassificationProbe.ts index 3195db8c56..b9db6b6c53 100644 --- a/src/artifacts/ArtifactClassificationProbe.ts +++ b/src/artifacts/ArtifactClassificationProbe.ts @@ -9,9 +9,9 @@ import { import { readFatContainerHeader, readFatContainerTable, - readFatSliceHeaderFacts, + readMachoHeaderPrefixFacts, type FatContainerTable, - type FatSliceHeaderFacts, + type MachoHeaderPrefixFacts, type FatSliceHeaderEvidence, } from "../domain/apple/machoContainer.js"; @@ -27,7 +27,7 @@ export interface ArtifactClassificationEvidence { const observeFatSliceHeaders = (input: { readonly table: FatContainerTable | undefined; - readonly observedHeaders: Map; + readonly observedHeaders: Map; readonly partialHeaders: Map< number, { readonly bytes: Buffer; count: number } @@ -58,7 +58,7 @@ const observeFatSliceHeaders = (input: { pending.count += end - start; observedHeaders.set( slice.offset, - readFatSliceHeaderFacts( + readMachoHeaderPrefixFacts( pending.bytes.subarray(0, Math.min(pending.count, headerLength)), ), ); @@ -72,7 +72,7 @@ const finishClassificationEvidence = ( prefix: Buffer, archiveBytes: number, finishAsar: (archiveBytes: number) => FinalAsarHeaderProbeObservation, - sliceHeadersByOffset: Map, + sliceHeadersByOffset: Map, ): ArtifactClassificationEvidence => { const finalHeader = readFatContainerHeader(prefix, archiveBytes); if (finalHeader.state === "parsed") { @@ -87,7 +87,7 @@ const finishClassificationEvidence = ( if (available === 0) continue; sliceHeadersByOffset.set( slice.offset, - readFatSliceHeaderFacts( + readMachoHeaderPrefixFacts( prefix.subarray(slice.offset, slice.offset + available), ), ); @@ -112,8 +112,8 @@ export const createArtifactClassificationProbe = () => { let retainedBytes = 0; let observedBytes = 0; let fatTable: FatContainerTable | undefined; - const fatSliceHeadersByOffset = new Map(); - const partialFatMagics = new Map< + const fatSliceHeadersByOffset = new Map(); + const partialFatHeaders = new Map< number, { readonly bytes: Buffer; count: number } >(); @@ -186,7 +186,7 @@ export const createArtifactClassificationProbe = () => { observeFatSliceHeaders({ table: fatTable, observedHeaders: fatSliceHeadersByOffset, - partialHeaders: partialFatMagics, + partialHeaders: partialFatHeaders, streamOffset, bytes, }); diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index 490b2d0c6d..5c67bd2af7 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -8,6 +8,7 @@ import { machoLoadCommandAlignment, classifyFatContainerIdentity, readFatContainerHeader, + readMachoHeaderPrefixFacts, validateFatSliceIdentity, type FatSliceDeclaration, } from "../../domain/apple/machoContainer.js"; @@ -259,8 +260,19 @@ const readSlice = async ( } | null = null, ): Promise => { const header = viewOf(await readExact(readAt, base, 28, "Mach-O header")); + const headerBytes = new Uint8Array( + header.buffer, + header.byteOffset, + header.byteLength, + ); + const identity = readMachoHeaderPrefixFacts(headerBytes).identity; const magic = header.getUint32(0, true); if (magic === MH_MAGIC_64 || magic === MH_MAGIC) { + if (identity === null) + throw new MachoFormatIssue( + "malformed", + `slice at offset ${base} has no Mach-O header`, + ); const wide = magic === MH_MAGIC_64; const headerSize = wide ? 32 : 28; const commandCount = header.getUint32(16, true); @@ -283,8 +295,8 @@ const readSlice = async ( ); const slice = decodeCommands(commands, { wide, - cpuType: header.getUint32(4, true), - cpuSubtype: header.getUint32(8, true), + cpuType: identity.cpuType, + cpuSubtype: identity.cpuSubtype, fileType: header.getUint32(12, true), commandCount, sliceOffset: base, diff --git a/src/artifacts/apple/machoContainer.test.ts b/src/artifacts/apple/machoContainer.test.ts index 83820dab18..3f584fa7d6 100644 --- a/src/artifacts/apple/machoContainer.test.ts +++ b/src/artifacts/apple/machoContainer.test.ts @@ -13,7 +13,7 @@ import { parseExecutableHeader } from "../../domain/binaryTarget.js"; import { classifyFatContainerIdentity, readFatContainerHeader, - readFatSliceHeaderFacts, + readMachoHeaderPrefixFacts, } from "../../domain/apple/machoContainer.js"; const classify = (bytes: Uint8Array) => { @@ -105,25 +105,25 @@ describe("FAT Mach-O identity", () => { 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({ + expect(readMachoHeaderPrefixFacts(littleEndian.subarray(0, 12))).toEqual({ magic: 0xcffaedfe, identity: { cpuType: CPU.arm64.type, cpuSubtype: CPU.arm64.subtype, }, }); - expect(readFatSliceHeaderFacts(bigEndian)).toEqual({ + expect(readMachoHeaderPrefixFacts(bigEndian)).toEqual({ magic: 0xfeedfacf, identity: { cpuType: CPU.x86_64.type, cpuSubtype: CPU.x86_64.subtype, }, }); - expect(readFatSliceHeaderFacts(littleEndian.subarray(0, 11))).toEqual({ + expect(readMachoHeaderPrefixFacts(littleEndian.subarray(0, 11))).toEqual({ magic: 0xcffaedfe, identity: null, }); - expect(readFatSliceHeaderFacts(littleEndian.subarray(0, 3))).toEqual({ + expect(readMachoHeaderPrefixFacts(littleEndian.subarray(0, 3))).toEqual({ magic: null, identity: null, }); diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 38de1a2be7..ad828bf97d 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -33,7 +33,7 @@ import { import { classifyFatContainerIdentity, readFatContainerHeader, - readFatSliceHeaderFacts, + readMachoHeaderPrefixFacts, } from "../../domain/apple/machoContainer.js"; /** Mutable internal occurrence used until root-bound IDs are known. */ @@ -617,8 +617,9 @@ const classifyFatArtifact = ( 0, Math.min(slice.size, prefix.length - offset, 12), ); - return readFatSliceHeaderFacts(prefix.subarray(offset, offset + available)) - .magic; + return readMachoHeaderPrefixFacts( + 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 104fe03329..06e75b3899 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -24,7 +24,7 @@ import { } from "./hashStableRootArtifact.js"; import type { ArtifactInventoryOptions } from "./types.js"; import type { FatSliceHeaderEvidence } from "../../domain/apple/machoContainer.js"; -import { readFatSliceHeaderFacts } from "../../domain/apple/machoContainer.js"; +import { readMachoHeaderPrefixFacts } from "../../domain/apple/machoContainer.js"; interface RootClassification { readonly format: ArtifactOccurrence["artifact_format"]; @@ -225,7 +225,7 @@ const readRootFatSliceHeaders = async ( } sliceHeadersByOffset.set( slice.offset, - readFatSliceHeaderFacts(thinHeader.subarray(0, bytesRead)), + readMachoHeaderPrefixFacts(thinHeader.subarray(0, bytesRead)), ); } return { header, sliceHeadersByOffset }; diff --git a/src/domain/apple/machoContainer.ts b/src/domain/apple/machoContainer.ts index f6ed4648ac..f4f1afaec1 100644 --- a/src/domain/apple/machoContainer.ts +++ b/src/domain/apple/machoContainer.ts @@ -37,11 +37,11 @@ export type FatContainerHeader = /** Exact slice-header observations collected by a complete, bounded reader. */ export interface FatSliceHeaderEvidence { readonly header: Extract; - readonly sliceHeadersByOffset: ReadonlyMap; + readonly sliceHeadersByOffset: ReadonlyMap; } -/** Parsed observations from the bounded prefix of one declared slice header. */ -export interface FatSliceHeaderFacts { +/** Parsed observations from the bounded prefix of a thin Mach-O header. */ +export interface MachoHeaderPrefixFacts { /** 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. */ @@ -52,9 +52,9 @@ export interface FatSliceHeaderFacts { } /** Parse only facts present in the observed prefix of a thin Mach-O header. */ -export const readFatSliceHeaderFacts = ( +export const readMachoHeaderPrefixFacts = ( bytes: Uint8Array, -): FatSliceHeaderFacts => { +): MachoHeaderPrefixFacts => { 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); diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index ec0a89afdb..9b265f2786 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -19,7 +19,7 @@ import { import { classifyFatContainerIdentity, readFatContainerHeader, - readFatSliceHeaderFacts, + readMachoHeaderPrefixFacts, validateFatSliceIdentity, type FatSliceHeaderEvidence, } from "./apple/machoContainer.js"; @@ -115,10 +115,12 @@ const parseThinMachO = ( return err( `truncated Mach-O header: a ${is64 ? 64 : 32}-bit header needs ${headerSize} bytes; the file has ${fileSize}`, ); + const identity = readMachoHeaderPrefixFacts(bytes.subarray(0, 12)).identity; + if (identity === null) return err("unsupported Mach-O architecture"); const little = magic === 0xcefaedfe || magic === 0xcffaedfe; const read = (offset: number): number => little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset); - const architecture = machArchitecture(read(4)); + const architecture = machArchitecture(identity.cpuType); if (architecture === undefined) return err("unsupported Mach-O architecture"); const commandBytes = read(20); if (headerSize + commandBytes > fileSize) @@ -195,21 +197,21 @@ const fatSliceIdentityError = ( >, sliceHeadersByOffset?: ReadonlyMap< number, - ReturnType + 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; + 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( + facts = readMachoHeaderPrefixFacts( bytes.subarray(declaration.offset, declaration.offset + 12), ); } @@ -247,7 +249,7 @@ const fatIdentityError = ( >, sliceHeadersByOffset?: ReadonlyMap< number, - ReturnType + ReturnType >, ): string | null => { const identity = classifyFatContainerIdentity(header, (offset) => { @@ -261,8 +263,9 @@ const fatIdentityError = ( 0, Math.min(slice.size, bytes.length - offset, 12), ); - return readFatSliceHeaderFacts(bytes.subarray(offset, offset + available)) - .magic; + return readMachoHeaderPrefixFacts( + bytes.subarray(offset, offset + available), + ).magic; }); if (identity.state === "needs-slice-header") return "FAT slice identity is outside the bounded prefix";