diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 7b163ec240..b878b17b18 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -8,7 +8,10 @@ coverage and unknowns. The CLI and MCP use the same application workflows. `inspect_macho.selected_architecture` identifies the exact selected lipo slice and its `container_file_offset` in the input file. A thin Mach-O has a known -offset of zero; an unknown universal-slice offset stays null. Architecture +offset of zero; an unknown universal-slice offset stays null. The Evidence +subject format describes the input container: `mach-o-universal` for FAT files, +including those with one slice. The normalized result describes the selected +Mach-O image. Architecture `file_offset` values are container-relative. Entrypoint, segment and section `file_offset` values retain their source meaning and are relative to the selected Mach-O slice. Raw load-command fields retain their original values. diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 078fc64310..b0b8e6a0d3 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -250,6 +250,7 @@ const detectArtifactFormat = async ( BinaryTarget["format"], | "analysis-database" | "mach-o" + | "mach-o-universal" | "elf" | "pe" | "dos-mz" @@ -315,6 +316,7 @@ const isArchiveFormat = ( BinaryTarget["format"], | "analysis-database" | "mach-o" + | "mach-o-universal" | "elf" | "pe" | "dos-mz" diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 6508d31423..1b8ec64112 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -98,6 +98,26 @@ const entry = ( }; describe("capability failure reasons", () => { + it("admits physical universal Mach-O targets for native Mach-O operations", () => { + expect( + entry( + "inspect_native_dispatch_metadata", + status({ + open: true, + kind: "executable", + format: "mach-o-universal", + capabilities: [ + { + operation: "inspect_native_dispatch_metadata", + available: true, + reason: null, + }, + ], + }), + ), + ).toMatchObject({ available: false, reason: "provider_missing" }); + }); + it.each([ { label: "requires a target", diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index ec38423765..1bf51884e1 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -19,6 +19,7 @@ import { } from "../contracts/toolOutputSchemaPrimitives.js"; import type { ToolKind } from "../contracts/toolContractTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { isMachOFormat } from "../domain/binaryTargetTypes.js"; import type { ProviderAvailability } from "./AnalysisProvider.js"; import { TOOL_CONTRACTS } from "../contracts/toolContracts.js"; import { @@ -549,7 +550,7 @@ const targetAvailability = ({ if ( targetOpen && MACHO_TARGET_OPERATIONS.has(name) && - (targetKind !== "executable" || targetFormat !== "mach-o") + (targetKind !== "executable" || !isMachOFormat(targetFormat)) ) return { reason: "target_unsupported", diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index c51bd7f13f..c95cec6d5e 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -21,7 +21,10 @@ import { artifactExtractionExecutionSchema, type ArtifactAnalysisOperation, } from "../contracts/artifactToolContracts.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isMachOFormat, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import { AnalysisCapabilityUnavailableError, AnalysisInputError, @@ -294,7 +297,7 @@ class ArtifactClient implements AnalysisClient { parameters: Readonly>, options?: ExecutionOptions, ) { - if (this.target.kind !== "executable" || this.target.format !== "mach-o") + if (this.target.kind !== "executable" || !isMachOFormat(this.target.format)) throw this.unsupportedTarget( "trace_dylib_resolution", "trace_dylib_resolution requires an active Mach-O or .app bundle target", diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 7dd23bcac0..6306a08bf2 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -31,7 +31,10 @@ import type { ArtifactResourceOwner, ArtifactResourceScope, } from "../ArtifactResourceScope.js"; -import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; +import { + isMachOFormat, + type BinaryTarget, +} from "../../domain/binaryTargetTypes.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import { scanArtifactInventoryInScope } from "../inventory/ArtifactInventory.js"; import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js"; @@ -418,7 +421,7 @@ const requireExtractableFormat = async ( if ( !directory && format !== "asar" && - format !== "mach-o" && + !isMachOFormat(format) && !isZipFormat(format) ) throw new AnalysisUnsupportedTargetError( diff --git a/src/contracts/toolOutputSchemaPrimitives.ts b/src/contracts/toolOutputSchemaPrimitives.ts index 6183017976..20c5517ee2 100644 --- a/src/contracts/toolOutputSchemaPrimitives.ts +++ b/src/contracts/toolOutputSchemaPrimitives.ts @@ -53,6 +53,7 @@ export const targetFormatSchema = z.enum([ "hopper", "analysis-database", "mach-o", + "mach-o-universal", "elf", "pe", "dos-mz", diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index bd37f79eb5..b79f387042 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -70,7 +70,7 @@ const snapshotWithHistoricalEvidence = () => { }; }; -describe("analysis snapshot contract", () => { +describe("analysis snapshot target identity", () => { it("partitions raw snapshots and query identities by the canonical entry", () => { const target: BinaryTarget = { path: "/fixture/image.bin", @@ -118,51 +118,64 @@ describe("analysis snapshot contract", () => { "entry_point", ); }); +}); - it("preserves DOS MZ target and subject formats with the x86 family", () => { - const target = { - path: ANALYSIS_SNAPSHOT_TARGET.path, - sha256: ANALYSIS_SNAPSHOT_TARGET.sha256, - kind: "executable" as const, - format: "dos-mz" as const, - architecture: "x86" as const, - availableArchitectures: ["x86" as const], - }; - const evidence = createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, { - operation: "analyze_function", - parameters: {}, - result: null, - analysisProfile: ANALYSIS_SNAPSHOT_PROFILE, - }); - const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE); - const parsed = analysisSnapshotSchema.parse({ - target: snapshotTarget(target), - binding, - entries: [ - { - query_id: `query_${"0".repeat(64)}`, - operation: "analyze_function", - parameters: {}, - execution: { - result: null, - raw_result: null, - provider: binding.provider, - limitations: [], - locations: [], - subject: target, +describe("analysis snapshot contract", () => { + it.each([ + ["dos-mz", "x86"], + ["mach-o-universal", "arm64"], + ] as const)( + "preserves %s target and subject formats", + (format, architecture) => { + const target = { + path: ANALYSIS_SNAPSHOT_TARGET.path, + sha256: ANALYSIS_SNAPSHOT_TARGET.sha256, + kind: "executable" as const, + format, + architecture, + availableArchitectures: [architecture], + }; + const evidence = createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: "analyze_function", + parameters: {}, + result: null, + analysisProfile: ANALYSIS_SNAPSHOT_PROFILE, + }); + const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE); + const parsed = parseAnalysisSnapshot({ + target: snapshotTarget(target), + binding, + entries: [ + { + query_id: analysisQueryId( + snapshotTarget(target), + binding, + "analyze_function", + {}, + ), + operation: "analyze_function", + parameters: {}, + execution: { + result: null, + raw_result: null, + provider: binding.provider, + limitations: [], + locations: [], + subject: target, + }, }, - }, - ], - workflow_entries: [], - evidence_bundle: createEvidenceBundle([evidence]), - }); - expect(parsed.target).toMatchObject({ - format: "dos-mz", - architecture: "x86", - }); - expect(parsed.entries[0]?.execution.subject?.format).toBe("dos-mz"); - expect(parsed.evidence_bundle.records[0]?.subject?.format).toBe("dos-mz"); - }); + ], + workflow_entries: [], + evidence_bundle: createEvidenceBundle([evidence]), + }); + expect(parsed.target).toMatchObject({ + format, + architecture, + }); + expect(parsed.entries[0]?.execution.subject?.format).toBe(format); + expect(parsed.evidence_bundle.records[0]?.subject?.format).toBe(format); + }, + ); it("rejects a snapshot subject path containing NUL", () => { const binding = snapshotBinding(ANALYSIS_SNAPSHOT_PROFILE); diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 2b99f9fafb..57e8766531 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -51,6 +51,7 @@ const architectureSchema = z.enum(BINARY_ARCHITECTURES); const formatSchema = z.enum([ "analysis-database", "mach-o", + "mach-o-universal", "elf", "pe", "dos-mz", @@ -81,7 +82,6 @@ const subjectSchema = z.object({ "directory", "file", "unknown", - "mach-o-universal", "javascript-bundle", "entitlements", ]), diff --git a/src/domain/binaryTarget.fixture.ts b/src/domain/binaryTarget.fixture.ts index bbc1dc5ddf..3e2c46147f 100644 --- a/src/domain/binaryTarget.fixture.ts +++ b/src/domain/binaryTarget.fixture.ts @@ -22,15 +22,32 @@ export const thinMach = (magic: number, cpu: number): Buffer => { return bytes; }; -/** Build a minimal FAT Mach-O table for binary-target parser tests. */ +/** Build a FAT Mach-O with in-bounds thin slices, or an explicitly truncated table. */ export const fat = ( cpus: readonly number[], declared = cpus.length, ): Buffer => { - const bytes = Buffer.alloc(8 + cpus.length * 20); + const tableSize = 8 + cpus.length * 20; + const slices = cpus.map((cpu) => { + const bytes = thinMach(0xfeedfacf, cpu); + bytes.writeUInt32BE(2, 12); + return bytes; + }); + const sliceStart = Math.ceil(tableSize / 16) * 16; + const bytes = Buffer.alloc( + declared === cpus.length ? sliceStart + cpus.length * 32 : tableSize, + ); bytes.writeUInt32BE(0xcafebabe, 0); bytes.writeUInt32BE(declared, 4); - cpus.forEach((cpu, index) => bytes.writeUInt32BE(cpu, 8 + index * 20)); + cpus.forEach((cpu, index) => { + const entry = 8 + index * 20; + const offset = sliceStart + index * 32; + bytes.writeUInt32BE(cpu, entry); + bytes.writeUInt32BE(offset, entry + 8); + bytes.writeUInt32BE(32, entry + 12); + bytes.writeUInt32BE(4, entry + 16); + if (declared === cpus.length) slices[index]?.copy(bytes, offset); + }); return bytes; }; diff --git a/src/domain/binaryTarget.test.ts b/src/domain/binaryTarget.test.ts index ed73f582cd..bf66eaa299 100644 --- a/src/domain/binaryTarget.test.ts +++ b/src/domain/binaryTarget.test.ts @@ -90,15 +90,30 @@ describe("executable header parsing", () => { const arm = parseExecutableHeader(bytes, "arm64"); const intel = parseExecutableHeader(bytes, "x64"); expect(arm.ok && arm.value).toMatchObject({ + format: "mach-o-universal", architecture: "arm64", availableArchitectures: ["x86_64", "arm64"], }); expect(intel.ok && intel.value).toMatchObject({ + format: "mach-o-universal", architecture: "x86_64", availableArchitectures: ["x86_64", "arm64"], }); }); + it("classifies even a one-slice FAT table as a universal container", () => { + const result = parseExecutableHeader(fat([0x0100000c]), "arm64"); + + expect(result).toMatchObject({ + ok: true, + value: { + format: "mach-o-universal", + architecture: "arm64", + availableArchitectures: ["arm64"], + }, + }); + }); + it("rejects FAT files without a host-compatible slice", () => { expect(parseExecutableHeader(fat([0x01000007]), "arm64")).toMatchObject({ ok: false, @@ -173,4 +188,111 @@ describe("executable header completeness", () => { // Only the slice REA would analyze must be present. expect(parseExecutableHeader(bytes, "x64", 4100).ok).toBe(true); }); + + it.each([ + { + label: "empty range", + corruptRange: (bytes: Buffer, wide: boolean, little: boolean) => + setFatSliceRange(bytes, wide, little, "size", 0n), + error: "FAT slice for arm64 is shorter than its Mach-O header", + }, + { + label: "table overlap", + corruptRange: (bytes: Buffer, wide: boolean, little: boolean) => + setFatSliceRange( + bytes, + wide, + little, + "offset", + BigInt(8 + 2 * (wide ? 32 : 20) - 1), + ), + error: "FAT slice for arm64 overlaps the architecture table", + }, + { + label: "undersized 64-bit header", + corruptRange: (bytes: Buffer, wide: boolean, little: boolean) => + setFatSliceRange(bytes, wide, little, "size", 31n), + error: "FAT slice for arm64 is shorter than its Mach-O header", + }, + ])("rejects a selected FAT slice with $label", ({ corruptRange, error }) => { + for (const [wide, little] of [ + [false, false], + [true, false], + [true, true], + ] as const) { + const bytes = wide + ? fat64([0x01000007, 0x0100000c], little) + : fat([0x01000007, 0x0100000c]); + expect(parseExecutableHeader(bytes, "arm64").ok).toBe(true); + corruptRange(bytes, wide, little); + expect(parseExecutableHeader(bytes, "arm64")).toMatchObject({ + ok: false, + error, + }); + } + }); + + it("uses a 28-byte minimum for a selected 32-bit Mach-O header", () => { + const exactHeader = fat([7]); + setFatSliceRange(exactHeader, false, false, "size", 28n, 0); + expect(parseExecutableHeader(exactHeader, "ia32").ok).toBe(true); + + const undersizedHeader = Buffer.from(exactHeader); + setFatSliceRange(undersizedHeader, false, false, "size", 27n, 0); + + expect(parseExecutableHeader(undersizedHeader, "ia32")).toMatchObject({ + ok: false, + error: "FAT slice for x86 is shorter than its Mach-O header", + }); + }); }); + +const setFatSliceRange = ( + bytes: Buffer, + wide: boolean, + littleEndian: boolean, + field: "offset" | "size", + value: bigint, + index = 1, +): void => { + const entry = 8 + index * (wide ? 32 : 20); + const fieldOffset = entry + (field === "offset" ? 8 : wide ? 16 : 12); + if (wide) { + if (littleEndian) bytes.writeBigUInt64LE(value, fieldOffset); + else bytes.writeBigUInt64BE(value, fieldOffset); + } else { + const numeric = Number(value); + if (littleEndian) bytes.writeUInt32LE(numeric, fieldOffset); + else bytes.writeUInt32BE(numeric, fieldOffset); + } +}; + +const fat64 = (cpus: readonly number[], littleEndian: boolean): Buffer => { + const tableEnd = 8 + cpus.length * 32; + const sliceStart = Math.ceil(tableEnd / 16) * 16; + const bytes = Buffer.alloc(sliceStart + cpus.length * 32); + if (littleEndian) { + bytes.writeUInt32LE(0xcafebabf, 0); + bytes.writeUInt32LE(cpus.length, 4); + } else { + bytes.writeUInt32BE(0xcafebabf, 0); + bytes.writeUInt32BE(cpus.length, 4); + } + cpus.forEach((cpu, index) => { + const entry = 8 + index * 32; + const sliceOffset = BigInt(sliceStart + index * 32); + if (littleEndian) { + bytes.writeUInt32LE(cpu, entry); + bytes.writeBigUInt64LE(sliceOffset, entry + 8); + bytes.writeBigUInt64LE(32n, entry + 16); + bytes.writeUInt32LE(4, entry + 24); + } else { + bytes.writeUInt32BE(cpu, entry); + bytes.writeBigUInt64BE(sliceOffset, entry + 8); + bytes.writeBigUInt64BE(32n, entry + 16); + bytes.writeUInt32BE(4, entry + 24); + } + thinMach(0xfeedfacf, cpu).copy(bytes, Number(sliceOffset)); + }); + return bytes; +}; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index ed4daf6cbf..66edd25908 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -15,7 +15,12 @@ export type ExecutableMetadata = readonly mips: MipsElfMetadata; } | { - readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; + readonly format: + | "mach-o" + | "mach-o-universal" + | "elf" + | "dos-mz" + | "dos-com"; readonly architecture: Exclude; readonly availableArchitectures: readonly Exclude< BinaryArchitecture, @@ -105,7 +110,10 @@ const parseFatMachO = ( if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize) return err("truncated or invalid FAT architecture table"); const architectures: Exclude[] = []; - const sliceEnds = new Map(); + const slices = new Map< + Exclude, + { readonly offset: bigint; readonly size: bigint } + >(); for (let index = 0; index < count; index += 1) { const entry = 8 + index * entrySize; const architecture = machArchitecture(read(entry)); @@ -122,7 +130,7 @@ const parseFatMachO = ( ? bytes.readBigUInt64LE(entry + 16) : bytes.readBigUInt64BE(entry + 16) : BigInt(read(entry + 12)); - sliceEnds.set(architecture, offset + size); + slices.set(architecture, { offset, size }); } const preferred = host === "arm64" @@ -134,15 +142,23 @@ const parseFatMachO = ( : host === "arm" ? "arm" : undefined; - if (preferred === undefined || !architectures.includes(preferred)) + const slice = preferred === undefined ? undefined : slices.get(preferred); + if (preferred === undefined || slice === undefined) return err(`FAT binary has no host-compatible ${host} architecture`); - const sliceEnd = sliceEnds.get(preferred) ?? 0n; + const tableEnd = BigInt(8 + count * entrySize); + if (slice.offset < tableEnd) + return err(`FAT slice for ${preferred} overlaps the architecture table`); + const minimumHeaderSize = + preferred === "arm64" || preferred === "x86_64" ? 32n : 28n; + if (slice.size < minimumHeaderSize) + return err(`FAT slice for ${preferred} is shorter than its Mach-O header`); + const sliceEnd = slice.offset + slice.size; if (sliceEnd > BigInt(fileSize)) return err( `truncated FAT slice: the ${preferred} slice ends at byte ${sliceEnd}; the file has ${fileSize}`, ); return ok({ - format: "mach-o", + format: "mach-o-universal", architecture: preferred, availableArchitectures: architectures, }); diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 55914377cd..3bd79144fa 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -83,7 +83,12 @@ export type BinaryTarget = readonly managed?: never; }) | (ExecutableTarget & { - readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com"; + readonly format: + | "mach-o" + | "mach-o-universal" + | "elf" + | "dos-mz" + | "dos-com"; readonly executableRole?: never; readonly managed?: never; }) @@ -130,3 +135,9 @@ export type BinaryTarget = readonly kind: "artifact"; readonly format: "plist" | "javascript" | "source-map"; }); + +/** Whether an executable target contains a thin or universal Mach-O image. */ +export const isMachOFormat = ( + format: unknown, +): format is "mach-o" | "mach-o-universal" => + format === "mach-o" || format === "mach-o-universal"; diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 18f0297589..059915d99c 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -664,6 +664,30 @@ describe("Ghidra native decompiler admission", () => { }); }); + it("accepts a one-slice universal-format Darwin native decompiler", () => { + const bytes = fat([0x0100000c]); + const result = inspectGhidraInstallation( + { + environment: {}, + installDir: INSTALL, + platform: "darwin", + architecture: "arm64", + }, + host({ + executableHeader: (path: string) => + path === MAC_DECOMPILER ? { bytes, size: bytes.length } : undefined, + }), + ); + expect(result.status).toBe("available"); + if (result.status !== "available") return; + expect( + result.checks.find(({ name }) => name === "native_decompiler"), + ).toMatchObject({ + status: "passed", + detail: `${MAC_DECOMPILER} reports mach-o-universal arm64, which matches darwin/arm64`, + }); + }); + it("names the observed and the required executable in the failure", () => { const result = inspectGhidraInstallation( { diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index ffdb2e0837..8d2043e6bf 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -17,7 +17,10 @@ import { parseExecutableHeader, type ExecutableMetadata, } from "../domain/binaryTarget.js"; -import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; +import { + isMachOFormat, + type BinaryArchitecture, +} from "../domain/binaryTargetTypes.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { ghidraApplicationProperties, @@ -131,7 +134,10 @@ const assessNativeDecompiler = ( }; const observed = `${parsed.value.format} ${parsed.value.architecture}`; const expected = `${expectedFormat} ${expectedArchitecture}`; - if (observed === expected) + const formatMatches = isMachOFormat(expectedFormat) + ? isMachOFormat(parsed.value.format) + : parsed.value.format === expectedFormat; + if (formatMatches && parsed.value.architecture === expectedArchitecture) return { status: "compatible", detail: `${path} reports ${observed}, which matches ${platform}/${architecture}`, diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 72d8ecd382..1c13280138 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -239,7 +239,7 @@ describe("Ghidra Mach-O slice support", () => { path: "/tmp/fixture", sha256: "a".repeat(64), kind: "executable", - format: "mach-o", + format: "mach-o-universal", architecture: "arm64", availableArchitectures: ["x86_64", "arm64"], }; @@ -256,6 +256,13 @@ describe("Ghidra Mach-O slice support", () => { expect( ghidra.inspectTargetSupport(executableTarget("mach-o", "arm64")), ).toMatchObject({ status: "supported" }); + + expect( + ghidra.inspectTargetSupport({ + ...universal, + availableArchitectures: ["arm64"] as const, + }), + ).toMatchObject({ status: "supported" }); }); }); diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index 98564faeb0..aa50440797 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -14,7 +14,10 @@ import { createAnalysisProfile, type AnalysisProfileCommitment, } from "../domain/analysisProfile.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isMachOFormat, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import { jsonObjectSchema, jsonValueSchema, @@ -164,7 +167,10 @@ export class GhidraProvider implements AnalysisProviderCandidate { reason: mipsReason, diagnostics, }; - if (target.format === "mach-o" && target.availableArchitectures.length > 1) + if ( + isMachOFormat(target.format) && + target.availableArchitectures.length > 1 + ) return { status: "unsupported", code: "architecture_unsupported", diff --git a/src/hopper/HopperAnalysisProfile.test.ts b/src/hopper/HopperAnalysisProfile.test.ts new file mode 100644 index 0000000000..b71e0d09e5 --- /dev/null +++ b/src/hopper/HopperAnalysisProfile.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from "vitest"; + +import { hopperLoaderArgsForTarget } from "./HopperAnalysisProfile.js"; + +it("routes physical universal Mach-O containers through Hopper's FAT loader", () => { + const result = hopperLoaderArgsForTarget( + { + path: "/tmp/universal", + sha256: "a".repeat(64), + kind: "executable", + format: "mach-o-universal", + architecture: "arm64", + availableArchitectures: ["x86_64", "arm64"], + }, + "fat64", + ); + + expect(result).toEqual({ + ok: true, + value: ["-l", "FAT", "--aarch64", "-l", "Mach-O"], + }); +}); diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 9dfef9e07e..927f7289b2 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -6,8 +6,11 @@ import type { ProviderIdentity, } from "../application/AnalysisProvider.js"; import { createAnalysisProfile } from "../domain/analysisProfile.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; -import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js"; +import { + isMachOFormat, + type BinaryTarget, + type BinaryArchitecture, +} from "../domain/binaryTargetTypes.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, @@ -37,7 +40,7 @@ export const resolveHopperAnalysisProfile = async ( if (target.kind !== "executable" && target.kind !== "database") return ok({ profile: null }); let machoContainer: "thin" | "fat32" | "fat64" | undefined; - if (target.kind === "executable" && target.format === "mach-o") { + if (target.kind === "executable" && isMachOFormat(target.format)) { const header = await readMachoMagic(target.path, options.signal); if (!header.ok) return header; machoContainer = [0xcafebabf, 0xbfbafeca].includes(header.value) @@ -119,21 +122,22 @@ export const hopperLoaderArgsForTarget = ( ), ); const flag = hopperArchitectureFlag(architecture); - switch (target.format) { - case "mach-o": - if (machoContainer === undefined) - return err( - new AnalysisCapabilityUnavailableError( - "hopper", - "resolve_analysis_profile", - "Mach-O container kind is unknown; resolve the Hopper analysis profile before starting its client.", - ), - ); - return ok( - machoContainer === "fat32" || machoContainer === "fat64" - ? ["-l", "FAT", flag, "-l", "Mach-O"] - : ["-l", "Mach-O", flag], + if (isMachOFormat(target.format)) { + if (machoContainer === undefined) + return err( + new AnalysisCapabilityUnavailableError( + "hopper", + "resolve_analysis_profile", + "Mach-O container kind is unknown; resolve the Hopper analysis profile before starting its client.", + ), ); + return ok( + machoContainer === "fat32" || machoContainer === "fat64" + ? ["-l", "FAT", flag, "-l", "Mach-O"] + : ["-l", "Mach-O", flag], + ); + } + switch (target.format) { case "elf": return ok(["-l", "ELF", flag]); case "pe": diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index 451289d92c..4995333161 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -2,7 +2,10 @@ import { createHash } from "node:crypto"; import { open, type FileHandle } from "node:fs/promises"; import { z } from "zod"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isMachOFormat, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { @@ -44,7 +47,7 @@ export const resolveHopperMachOImage = async ( ), ); if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); - if (target.format !== "mach-o") + if (!isMachOFormat(target.format) || target.architecture === "mips") return invalid("Hopper FAT64 image preparation requires a Mach-O target"); let file: FileHandle; try { diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index 1b54c42b84..26ff8caaf5 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -4,7 +4,10 @@ import { nativeDispatchMetadataResultSchema, type ObjcSwiftMetadata, } from "../domain/native/objcSwiftMetadata.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isMachOFormat, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import { AnalysisCancelledError, AnalysisResourceConstraintError, @@ -174,7 +177,7 @@ export const inspectAppleDispatchMetadata = async ( maxRecords: number, signal?: AbortSignal, ) => { - if (target.kind !== "executable" || target.format !== "mach-o") + if (target.kind !== "executable" || !isMachOFormat(target.format)) throw new TypeError("Apple dispatch metadata requires a Mach-O target"); const maximumBytes = 64 * 1024 * 1024; const capacityError = () => diff --git a/src/native/NativeCallObservation.ts b/src/native/NativeCallObservation.ts index b9ad41620b..bb3960b569 100644 --- a/src/native/NativeCallObservation.ts +++ b/src/native/NativeCallObservation.ts @@ -7,7 +7,10 @@ import { AnalysisInputError, } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isMachOFormat, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -40,7 +43,7 @@ export const observeNativeCalls = async ( signal?: AbortSignal, readTargetDigest: typeof fileSha256 = fileSha256, ): Promise> => { - if (target.kind !== "executable" || target.format !== "mach-o") + if (target.kind !== "executable" || !isMachOFormat(target.format)) return err( new AnalysisCapabilityUnavailableError( "native-macos", diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 8ab92abacd..2ae6a7cb66 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -24,7 +24,10 @@ import { NATIVE_TOOL_CONTRACTS, type NativeToolName, } from "../contracts/native/nativeToolContracts.js"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isMachOFormat, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import { toolContract } from "../contracts/toolContracts.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import type { EvidenceLocation } from "../domain/evidence.js"; @@ -167,7 +170,10 @@ class NativeMacOSClient implements AnalysisClient { }, ), ); - if (this.target.kind !== "executable" || this.target.format !== "mach-o") + if ( + this.target.kind !== "executable" || + !isMachOFormat(this.target.format) + ) return err( new AnalysisCapabilityUnavailableError( IDENTITY.id, @@ -454,7 +460,7 @@ class NativeMacOSClient implements AnalysisClient { ): Promise> { // lipo reads only Mach-O; its refusal of a PE, ELF, or plist target is not // a tool failure that a retry or `rea doctor` could repair. - if (this.target.format !== "mach-o") + if (!isMachOFormat(this.target.format)) return err( new AnalysisCapabilityUnavailableError( IDENTITY.id, @@ -782,7 +788,7 @@ class NativeMacOSClient implements AnalysisClient { async #inspectMacho( signal?: AbortSignal, ): Promise> { - if (this.target.format !== "mach-o") + if (!isMachOFormat(this.target.format)) return err( new AnalysisCapabilityUnavailableError( IDENTITY.id, @@ -995,7 +1001,7 @@ const codeSignArchitectures = ( ); if (architectures.length > 0) return [...new Set(architectures)]; } - if (target.kind !== "executable" || target.format !== "mach-o") return []; + if (target.kind !== "executable" || !isMachOFormat(target.format)) return []; return target.availableArchitectures.map((architecture) => architecture === "x86" ? "i386" : architecture, ); diff --git a/src/native/NativeUiObservation.ts b/src/native/NativeUiObservation.ts index 021e0d1322..1256f59494 100644 --- a/src/native/NativeUiObservation.ts +++ b/src/native/NativeUiObservation.ts @@ -1,6 +1,9 @@ import { fileURLToPath } from "node:url"; import { z } from "zod"; -import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; +import { + isMachOFormat, + type BinaryTarget, +} from "../domain/binaryTargetTypes.js"; import { nativeUiObservationInputSchema, nativeUiScenarioInputSchema, @@ -78,7 +81,7 @@ const observeWithHelper = async ( ).safeParse(parameters); if (!parsed.success) return err(new AnalysisInputError(operation, { cause: parsed.error })); - if (target.kind !== "executable" || target.format !== "mach-o") + if (target.kind !== "executable" || !isMachOFormat(target.format)) return err( new AnalysisCapabilityUnavailableError( "native-macos", diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 070a50f462..3ab9bdd27e 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -20,12 +20,47 @@ import { } from "../../../src/application/AppBundleExecutable.js"; import { dosMz, + fat, pe, thinMach, } from "../../../src/domain/binaryTarget.fixture.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +it.each([{ cpus: [0x0100000c] }, { cpus: [0x01000007, 0x0100000c] }])( + "preserves a FAT container's format in the resolved target and Evidence ($cpus)", + async ({ cpus }) => { + const directory = await createTestTempDirectory("rea-universal-target-"); + const path = join(directory, "image"); + await writeFile(path, fat(cpus)); + const resolved = await parseBinaryTarget(path, { + hostArchitecture: "arm64", + }); + if (!resolved.ok) throw resolved.error; + expect(resolved.value).toMatchObject({ + kind: "executable", + format: "mach-o-universal", + architecture: "arm64", + }); + const evidence = createEvidence( + resolved.value, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "inspect_macho", + parameters: {}, + result: null, + }, + ); + expect( + parseEvidence(JSON.parse(JSON.stringify(evidence))).subject, + ).toMatchObject({ + format: "mach-o-universal", + architecture: "arm64", + }); + }, +); + describe("binary target I/O: app plist permissions and decoding", () => { it.each([ ["EACCES", "permission denied reading app Info.plist"], diff --git a/tests/boundary/mcp/dylibResolutionMcp.test.ts b/tests/boundary/mcp/dylibResolutionMcp.test.ts index 4f2d70aab4..4040935aed 100644 --- a/tests/boundary/mcp/dylibResolutionMcp.test.ts +++ b/tests/boundary/mcp/dylibResolutionMcp.test.ts @@ -8,11 +8,13 @@ import { z } from "zod"; import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; import { + CPU, FILE_TYPE, LC, buildVersionCommand, dylibCommand, dyldEnvironmentCommand, + fatImage, machoImage, rpathCommand, } from "../../../src/artifacts/apple/MachoImage.fixture.js"; @@ -112,6 +114,44 @@ it("traces dylib resolution for an opened app bundle and rejects a changed targe }); }); +it("traces architecture slices from a universal Mach-O target", async () => { + const root = await createTestTempDirectory("rea-dylib-universal-"); + const executable = join(root, "Universal"); + const cpu = process.arch === "arm64" ? CPU.arm64 : CPU.x86_64; + const architecture = process.arch === "arm64" ? "arm64" : "x86_64"; + await writeFile( + executable, + fatImage([ + { + cpu, + bytes: machoImage({ + cpu, + commands: [buildVersionCommand(1)], + }), + }, + ]), + ); + + await withClient(async (client) => { + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: executable }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const called = await client.callTool({ + name: "trace_dylib_resolution", + arguments: { architecture }, + }); + expect(called.isError, JSON.stringify(called.structuredContent)).not.toBe( + true, + ); + const trace = dylibResolutionResultSchema.parse( + structuredResult(called.structuredContent), + ); + expect(trace.roots).toEqual([{ image: "Universal", architecture }]); + }); +}); + it("rejects targets that are not Mach-O", async () => { const directory = await createTestTempDirectory("rea-dylib-mcp-plist-"); const path = join(directory, "Info.plist"); diff --git a/tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts b/tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts index 5576fccef7..5d8f9f8287 100644 --- a/tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts +++ b/tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts @@ -7,57 +7,75 @@ import { NativeMacOSProvider } from "../../../src/native/NativeMacOSProvider.js" import { machoImage, rpathCommand, + CPU, + fatImage, } from "../../../src/artifacts/apple/MachoImage.fixture.js"; import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { NativeFixtureRunner } from "../../fixtures/nativeCommands.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; -it("rejects changed signature targets through MCP and accepts reopening the new version", async () => { - const path = join( - await createTestTempDirectory("rea-signature-binding-mcp-"), - "program", - ); - await writeFile(path, machoImage({})); - const session = createTestBinarySession( - new NativeMacOSProvider({}, new NativeFixtureRunner(), "darwin"), - ); - const server = createServer({ kind: "session", session }); - const client = new Client({ name: "signature-binding-test", version: "1" }); - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - try { - await server.connect(serverTransport); - await client.connect(clientTransport); - const open = () => - client.callTool({ name: "open_binary", arguments: { path } }); - expect((await open()).isError).not.toBe(true); - await writeFile( - path, - machoImage({ commands: [rpathCommand("new-version")] }), +it.each(["mach-o", "mach-o-universal"] as const)( + "rejects changed %s signature targets through MCP and accepts reopening the new version", + async (format) => { + const path = join( + await createTestTempDirectory("rea-signature-binding-mcp-"), + "program", ); - const changed = await client.callTool({ - name: "inspect_signature", - arguments: {}, - }); - expect(changed.isError).toBe(true); - expect(parseMcpToolError(changed)).toMatchObject({ - error: { code: "artifact_changed", details: { path } }, - }); - expect((await open()).isError).not.toBe(true); - const inspected = await client.callTool({ - name: "inspect_signature", - arguments: {}, - }); - expect( - inspected.isError, - JSON.stringify(inspected.structuredContent), - ).not.toBe(true); - expect(inspected.structuredContent).toMatchObject({ - normalized_result: { signed: true, identifier: "com.example.fixture" }, - }); - } finally { - await client.close(); - await server.close(); - } -}); + const image = (commands: readonly Uint8Array[] = []) => + format === "mach-o" + ? machoImage({ commands }) + : fatImage( + [CPU.arm64, CPU.x86_64].map((cpu) => ({ + cpu, + bytes: machoImage({ cpu, commands }), + })), + ); + await writeFile(path, image()); + const session = createTestBinarySession( + new NativeMacOSProvider({}, new NativeFixtureRunner(), "darwin"), + ); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "signature-binding-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const open = () => + client.callTool({ name: "open_binary", arguments: { path } }); + const opened = await open(); + expect(opened.isError).not.toBe(true); + expect( + toolContract("open_binary").outputSchema.parse(opened.structuredContent) + .result, + ).toMatchObject({ format }); + await writeFile(path, image([rpathCommand("new-version")])); + const changed = await client.callTool({ + name: "inspect_signature", + arguments: {}, + }); + expect(changed.isError).toBe(true); + expect(parseMcpToolError(changed)).toMatchObject({ + error: { code: "artifact_changed", details: { path } }, + }); + expect((await open()).isError).not.toBe(true); + const inspected = await client.callTool({ + name: "inspect_signature", + arguments: {}, + }); + expect( + inspected.isError, + JSON.stringify(inspected.structuredContent), + ).not.toBe(true); + expect(inspected.structuredContent).toMatchObject({ + subject: { format }, + normalized_result: { signed: true, identifier: "com.example.fixture" }, + }); + } finally { + await client.close(); + await server.close(); + } + }, +); diff --git a/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts b/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts new file mode 100644 index 0000000000..f451c3863f --- /dev/null +++ b/tests/boundary/providers/native/artifactProvider.universalExtraction.test.ts @@ -0,0 +1,73 @@ +import { access, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it, vi } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; +import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js"; +import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js"; +import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js"; + +const sliceReader = vi.hoisted(() => ({ + reached: vi.fn(), + failure: null as unknown, +})); + +vi.mock("../../../../src/artifacts/MachOSliceArtifactReader.js", () => ({ + MachOSliceArtifactReader: class { + async *entries() { + sliceReader.reached(); + yield* []; + throw sliceReader.failure; + } + + close() { + return Promise.resolve(); + } + + provenance() { + return []; + } + }, +})); + +it("routes universal Mach-O extraction through slice inventory", async () => { + sliceReader.reached.mockClear(); + sliceReader.failure = new ArtifactReaderFailure( + "unavailable", + "fixture reached universal slice inventory", + ); + const root = await createTestTempDirectory("rea-extract-universal-"); + const image = join(root, "Universal"); + await writeFile(image, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0])); + const output = join(root, "output"); + const result = await new ArtifactProvider(process.env) + .createClient(universalTarget(image)) + .execute( + "extract_artifact", + artifactExtractionExecutionSchema.parse({ output_root: output }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "ArtifactOperationError", + reason: "unavailable", + detail: "fixture reached universal slice inventory", + }, + }); + expect(sliceReader.reached).toHaveBeenCalledOnce(); + await expect(access(output)).rejects.toThrow(); +}); + +const universalTarget = (path: string): BinaryTarget => ({ + path, + sourcePath: path, + sha256: "0".repeat(64), + kind: "executable", + format: "mach-o-universal", + architecture: "arm64", + availableArchitectures: ["arm64"], +});