From b928963675a735df2577d67bbd02f4707417a6e6 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 21:18:35 -0700 Subject: [PATCH] feat(native): inspect a universal Mach-O slice in Go and Rust metadata readers (#6219) * feat(native): inspect a universal Mach-O slice in Go and Rust metadata readers inspect_go_binary and inspect_rust_binary refused universal Mach-O files and offered no slice selection, so hosts without lipo could not inspect them. Both now accept architecture (x86, x86_64, arm, arm64). One selector reads REA's own FAT table, validates the slice range, alignment and header CPU, and returns the thin image with its macho-selected-image-v1 identity. The universal file stays the artifact; results add selected_slice and keep every source range a file offset. Missing, absent, duplicate or misplaced selections fail on architecture and list the slices present. Subject format mach-o-universal now has one owner shared by the services and the retained Go/Rust views. Fixes #4102 Co-Authored-By: Claude Opus 5.5 * fix(native): reject damaged universal files and self-contradicting slice reports A FAT table whose slice could not be read was treated as a non-universal file, so callers were told to omit architecture. Its reason now fails on path. Go and Rust reports with a selected slice must be Mach-O and name the slice's CPU family, through one shared agreement check. The CLI architecture flag is described in help. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../references/native-and-artifacts.md | 6 +- docs/go-binaries.md | 20 +- docs/rust-binaries.md | 15 +- src/application/go/GoBinaryService.ts | 3 +- src/application/rust/RustBinaryService.ts | 6 +- src/cli/goCommands.ts | 21 +- src/cli/rustCommands.ts | 24 +- src/contracts/go/goToolContracts.ts | 2 +- src/contracts/rust/rustToolContracts.ts | 2 +- src/domain/analysisView/goBinaryView.ts | 8 +- src/domain/analysisView/rustBinaryView.ts | 7 +- src/domain/apple/machoTargetSelection.ts | 63 ++- src/domain/go/goBinary.ts | 44 +++ src/domain/rust/rustBinary.ts | 51 +++ src/go/GoBinaryProvider.ts | 36 +- src/native/metadata/MachoSliceSelection.ts | 149 +++++++ src/rust/RustBinaryProvider.ts | 45 ++- tests/boundary/mcp/universalSliceMcp.test.ts | 373 ++++++++++++++++++ tests/fixtures/universalMacho.ts | 62 +++ 19 files changed, 902 insertions(+), 35 deletions(-) create mode 100644 src/native/metadata/MachoSliceSelection.ts create mode 100644 tests/boundary/mcp/universalSliceMcp.test.ts create mode 100644 tests/fixtures/universalMacho.ts diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 4e549ce493..c35d62a3cc 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -45,7 +45,8 @@ See [desktop installations](https://github.com/morluto/rea/blob/main/docs/deskto ## Go build metadata -Use `inspect_go_binary` for an explicit local ELF, PE or thin Mach-O when the +Use `inspect_go_binary` for an explicit local ELF, PE or Mach-O (universal +files need `architecture`) when the question concerns the embedded compiler version, module/dependency versions, local replacements or build settings. No `open_binary`, Go installation or target execution is required. The CLI equivalent is `inspect-go-binary PATH`. @@ -84,7 +85,8 @@ See [IL2CPP metadata](https://github.com/morluto/rea/blob/main/docs/il2cpp-metad ## Rust dependency metadata -Use `inspect_rust_binary` for an explicit local ELF, PE, or thin Mach-O when +Use `inspect_rust_binary` for an explicit local ELF, PE, or Mach-O (universal +files need `architecture`) when the question concerns the cargo-auditable crate list embedded in `.dep-v0` (Mach-O: `__DATA,.dep-v0`). No `open_binary`, Rust installation, or target execution is required. The CLI equivalent is `inspect-rust-binary PATH`. diff --git a/docs/go-binaries.md b/docs/go-binaries.md index 82e5aebf80..42ae92791a 100644 --- a/docs/go-binaries.md +++ b/docs/go-binaries.md @@ -109,11 +109,21 @@ module record and no dependency records has an observed empty dependency list. ## Supported boundary -The built-in TypeScript reader supports ELF, PE and thin Mach-O images with Go's -inline or historical pointer build-info encoding. Source ranges refer to the -original stable file snapshot, including pointer-backed strings. It does not -scan arbitrary byte strings as compiler metadata or interpret a fat Mach-O -container as one selected architecture. +The built-in TypeScript reader supports ELF, PE and thin Mach-O images, and one +selected slice of a universal Mach-O, with Go's inline or historical pointer +build-info encoding. Source ranges refer to the original stable file snapshot, +including pointer-backed strings. It does not scan arbitrary byte strings as +compiler metadata. + +A universal (FAT) Mach-O needs `architecture` (`x86`, `x86_64`, `arm` +or `arm64`; CLI `--architecture`). REA reads its own FAT table, without `lipo`, +and validates the selected slice's range, alignment and Mach-O header CPU +against the table. The result keeps the universal file as `artifact` (its +SHA-256 and size), adds `selected_slice` with the slice offset, length, raw CPU +type/subtype and the FAT table digest, and reports every source range as a +file offset inside that slice. Without `architecture`, or when the file has no +such slice or several, the request fails on `architecture` and lists the slices +present. `architecture` on any other image is rejected rather than ignored. For ELF images without a named build-info section, discovery checks each nonempty writable, non-executable load segment in program-header order. This diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md index a2cc0ff6fe..76f823198f 100644 --- a/docs/rust-binaries.md +++ b/docs/rust-binaries.md @@ -86,7 +86,7 @@ The inline Evidence result contains the artifact path, SHA-256, byte length, container format, architecture, word size, and byte order. When a `.dep-v0` section is present, `auditable_metadata` contains: -- `section_name`, `.dep-v0` for ELF and PE or `__DATA,.dep-v0` for thin Mach-O; +- `section_name`, `.dep-v0` for ELF and PE or `__DATA,.dep-v0` for Mach-O; - `file_offset` and `section_bytes`, the exact file range of the compressed section; - `compressed_bytes` and `uncompressed_bytes`; - `format_version`, the embedded integer, or `null` when the field is omitted; @@ -124,8 +124,17 @@ verified. An omitted revision remains `null` and follows revision 0 semantics. Embedded names and versions are file content. They do not authenticate the packages or the compiler. -Universal Mach-O containers are unsupported. Select a thin slice first. ELF, -PE, and thin Mach-O are the supported containers. +ELF, PE, thin Mach-O and one selected slice of a universal Mach-O are the +supported containers. +A universal (FAT) Mach-O needs `architecture` (`x86`, `x86_64`, `arm` +or `arm64`; CLI `--architecture`). REA reads its own FAT table, without `lipo`, +and validates the selected slice's range, alignment and Mach-O header CPU +against the table. The result keeps the universal file as `artifact` (its +SHA-256 and size), adds `selected_slice` with the slice offset, length, raw CPU +type/subtype and the FAT table digest, and reports the section and rustc-path offsets as a +file offset inside that slice. Without `architecture`, or when the file has no +such slice or several, the request fails on `architecture` and lists the slices +present. `architecture` on any other image is rejected rather than ignored. Package and dependency counts are checked against the minimum required JSON representation before graph allocation or schema cloning. Oversized graphs diff --git a/src/application/go/GoBinaryService.ts b/src/application/go/GoBinaryService.ts index 88ea351b4a..4013fe3af5 100644 --- a/src/application/go/GoBinaryService.ts +++ b/src/application/go/GoBinaryService.ts @@ -1,3 +1,4 @@ +import { metadataReportSubjectFormat } from "../../domain/apple/machoTargetSelection.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { GoBinaryPort } from "./GoBinaryPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -73,7 +74,7 @@ export class GoBinaryService { { path: value.artifact.path, sha256: value.artifact.sha256, - format: value.format === "macho" ? "mach-o" : value.format, + format: metadataReportSubjectFormat(value), ...(architecture === undefined ? {} : { architecture }), }, this.provider.identity, diff --git a/src/application/rust/RustBinaryService.ts b/src/application/rust/RustBinaryService.ts index 460dab04ad..4cf8b7d60e 100644 --- a/src/application/rust/RustBinaryService.ts +++ b/src/application/rust/RustBinaryService.ts @@ -1,3 +1,4 @@ +import { metadataReportSubjectFormat } from "../../domain/apple/machoTargetSelection.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { RustBinaryPort } from "./RustBinaryPort.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; @@ -9,6 +10,7 @@ import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { rustArchitectureForSubject, rustBinarySchema, + type InspectRustBinaryInput, } from "../../domain/rust/rustBinary.js"; import { inspectRustBinaryRequestSchema } from "../../domain/rust/rustBinaryQuery.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; @@ -82,7 +84,7 @@ export class RustBinaryService { const evidenceFor = ( provider: RustBinaryPort["identity"], - parameters: { readonly path: string }, + parameters: InspectRustBinaryInput, value: ReturnType, ): Evidence => { const architecture = rustArchitectureForSubject(value.architecture); @@ -91,7 +93,7 @@ const evidenceFor = ( { path: value.artifact.path, sha256: value.artifact.sha256, - format: value.format === "macho" ? "mach-o" : value.format, + format: metadataReportSubjectFormat(value), ...(architecture === undefined ? {} : { architecture }), }, provider, diff --git a/src/cli/goCommands.ts b/src/cli/goCommands.ts index 84f76659a9..a5adab8ae3 100644 --- a/src/cli/goCommands.ts +++ b/src/cli/goCommands.ts @@ -10,6 +10,7 @@ import { deliverCliAnalysisEvidence, } from "./analysisEvidenceDelivery.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { machoSliceArchitectureSchema } from "../domain/apple/machoTargetSelection.js"; /** Resolve caller paths and inspect static build metadata through the shared workflow. */ export const registerGoCommands = (cli: CliInstance, logger: Logger): void => { @@ -17,9 +18,18 @@ export const registerGoCommands = (cli: CliInstance, logger: Logger): void => { description: "Inspect embedded Go compiler, modules and build settings without target execution", args: z.object({ - path: z.string().describe("Local ELF, PE or thin Mach-O image"), + path: z + .string() + .describe("Local ELF, PE, thin Mach-O or universal Mach-O image"), + }), + options: z.object({ + ...analysisEvidenceDeliveryOptions, + architecture: machoSliceArchitectureSchema + .optional() + .describe( + "CPU family of the slice to read from a universal Mach-O; required for universal files", + ), }), - options: z.object(analysisEvidenceDeliveryOptions), run: ({ args, options }) => withCommandCancellation(async (signal) => { const service = createGoBinaryService(); @@ -30,7 +40,12 @@ export const registerGoCommands = (cli: CliInstance, logger: Logger): void => { deliverCliAnalysisEvidence({ run: () => service.inspect( - { path: absoluteCallerPath(args.path) }, + { + path: absoluteCallerPath(args.path), + ...(options.architecture === undefined + ? {} + : { architecture: options.architecture }), + }, { signal }, ), service, diff --git a/src/cli/rustCommands.ts b/src/cli/rustCommands.ts index 2dbb44bfc0..62be1ab645 100644 --- a/src/cli/rustCommands.ts +++ b/src/cli/rustCommands.ts @@ -15,6 +15,7 @@ import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { machoSliceArchitectureSchema } from "../domain/apple/machoTargetSelection.js"; /** Resolve caller paths and inspect embedded Rust dependencies through the shared workflow. */ export const registerRustCommands = ( @@ -25,10 +26,17 @@ export const registerRustCommands = ( description: "Inspect an embedded cargo-auditable Rust dependency list without target execution", args: z.object({ - path: z.string().describe("Local ELF, PE or thin Mach-O image"), + path: z + .string() + .describe("Local ELF, PE, thin Mach-O or universal Mach-O image"), }), options: z.object({ ...analysisEvidenceDeliveryOptions, + architecture: machoSliceArchitectureSchema + .optional() + .describe( + "CPU family of the slice to read from a universal Mach-O; required for universal files", + ), detail: z .enum(["complete", "summary"]) .optional() @@ -67,6 +75,9 @@ export const registerRustCommands = ( const view = decoded.value; const input = inspectRustBinaryRequestSchema.safeParse({ path: absoluteCallerPath(args.path), + ...(options.architecture === undefined + ? {} + : { architecture: options.architecture }), ...(options.detail === undefined ? {} : { detail: options.detail }), ...(view === undefined ? {} : { view }), }); @@ -80,7 +91,16 @@ export const registerRustCommands = ( ); const service = createRustBinaryService(); return deliverCliAnalysisEvidence({ - run: () => service.inspect({ path: input.data.path }, { signal }), + run: () => + service.inspect( + { + path: input.data.path, + ...(input.data.architecture === undefined + ? {} + : { architecture: input.data.architecture }), + }, + { signal }, + ), service, providerId: "rust-auditable", operation: "inspect_rust_binary", diff --git a/src/contracts/go/goToolContracts.ts b/src/contracts/go/goToolContracts.ts index 3638aeec39..b9dc6a119a 100644 --- a/src/contracts/go/goToolContracts.ts +++ b/src/contracts/go/goToolContracts.ts @@ -14,7 +14,7 @@ export const GO_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_go_binary"), kind: "artifact-provider", description: - "Inspect embedded Go compiler version, main module, dependency/replacement versions and build settings in an explicit local ELF, PE or thin Mach-O image. Default detail complete returns original artifact SHA-256, exact build-info source ranges, raw framed module bytes and unparsed module-text lines inline as observed Evidence. Explicit detail summary retains the same complete parent and returns compiler/main-module identity, counts, source ranges and parent_evidence_id. Query retained rows through inspect_analysis_view kind go, collection dependencies or settings; select exact dependency paths or setting keys, indices, fields and pagination. Rows always retain their original index scoped to the parent Evidence; empty filters select none. Runtime dependency completeness remains unknown. Supports inline and historical pointer encodings without installing Go, opening a disassembler, executing the target, writing temporary files or accessing the network. Missing metadata is reported as unknown, not proof that an image is not Go. Does not recover functions, source mappings, types or runtime behavior. Resource guards bound files to 256 MiB and charge each distinct build-info search span in full against a cumulative 256 MiB header-search budget, with partial overlaps charged separately; they also bound aggregate embedded strings to 1 MiB, structural table decoding to 16 MiB and complete inline metadata to 8 MiB; malformed, unsupported and oversized inputs receive typed failures rather than partial success.", + "Inspect embedded Go compiler version, main module, dependency/replacement versions and build settings in an explicit local ELF, PE or thin Mach-O image, or the slice of a universal Mach-O named by architecture (read without lipo; artifact stays the universal file, selected_slice records the slice and source ranges stay file offsets). Default detail complete returns original artifact SHA-256, exact build-info source ranges, raw framed module bytes and unparsed module-text lines inline as observed Evidence. Explicit detail summary retains the same complete parent and returns compiler/main-module identity, counts, source ranges and parent_evidence_id. Query retained rows through inspect_analysis_view kind go, collection dependencies or settings; select exact dependency paths or setting keys, indices, fields and pagination. Rows always retain their original index scoped to the parent Evidence; empty filters select none. Runtime dependency completeness remains unknown. Supports inline and historical pointer encodings without installing Go, opening a disassembler, executing the target, writing temporary files or accessing the network. Missing metadata is reported as unknown, not proof that an image is not Go. Does not recover functions, source mappings, types or runtime behavior. Resource guards bound files to 256 MiB and charge each distinct build-info search span in full against a cumulative 256 MiB header-search budget, with partial overlaps charged separately; they also bound aggregate embedded strings to 1 MiB, structural table decoding to 16 MiB and complete inline metadata to 8 MiB; malformed, unsupported and oversized inputs receive typed failures rather than partial success.", inputSchema: inspectGoBinaryInputSchema, outputSchema: analysisDetailOutputSchemaOf( goBinarySchema, diff --git a/src/contracts/rust/rustToolContracts.ts b/src/contracts/rust/rustToolContracts.ts index 966a057dbe..87bc1b22d7 100644 --- a/src/contracts/rust/rustToolContracts.ts +++ b/src/contracts/rust/rustToolContracts.ts @@ -16,7 +16,7 @@ export const RUST_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_rust_binary"), kind: "artifact-provider", description: - "Inspect the cargo-auditable crate dependency list embedded in an explicit local ELF, PE or thin Mach-O image. Returns the artifact SHA-256, the .dep-v0 section file offset and size, compressed and uncompressed sizes, format version, packages in their original order, the root package index, and dependency edges by index. Omitted or explicit complete detail preserves the full report. Optional detail summary retains that complete Evidence and returns source ranges, counts and parent_evidence_id. Optional view directly selects packages or edges in this call; it cannot be combined with explicit complete detail. Query the same retained facts with inspect_analysis_view kind rust: exact names/versions or package indices, edge from_indices/to_indices, fields and matching-row pagination. Row indices are original and scoped to the parent Evidence; empty filters select none. Default package fields omit dependency vectors; explicitly select dependencies for direct index references. Every matching observed version is retained. No current runtime state is inferred from historical reads. Reads zlib-compressed JSON without installing Rust, opening a disassembler, executing the target, writing temporary files or accessing the network. A missing section is null metadata with a limitation and is not proof that the binary has no dependencies. A rustc commit hash is reported only as an inference when a /rustc/<40-hex>/ path is present in read-only data. Resource guards bound files to 256 MiB, dependency sections to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the compressed size, and the complete report to 8 MiB. Malformed, unsupported and oversized inputs receive typed failures rather than partial success.", + "Inspect the cargo-auditable crate dependency list embedded in an explicit local ELF, PE or thin Mach-O image, or the slice of a universal Mach-O named by architecture (read without lipo; artifact stays the universal file, selected_slice records the slice and offsets stay file offsets). Returns the artifact SHA-256, the .dep-v0 section file offset and size, compressed and uncompressed sizes, format version, packages in their original order, the root package index, and dependency edges by index. Omitted or explicit complete detail preserves the full report. Optional detail summary retains that complete Evidence and returns source ranges, counts and parent_evidence_id. Optional view directly selects packages or edges in this call; it cannot be combined with explicit complete detail. Query the same retained facts with inspect_analysis_view kind rust: exact names/versions or package indices, edge from_indices/to_indices, fields and matching-row pagination. Row indices are original and scoped to the parent Evidence; empty filters select none. Default package fields omit dependency vectors; explicitly select dependencies for direct index references. Every matching observed version is retained. No current runtime state is inferred from historical reads. Reads zlib-compressed JSON without installing Rust, opening a disassembler, executing the target, writing temporary files or accessing the network. A missing section is null metadata with a limitation and is not proof that the binary has no dependencies. A rustc commit hash is reported only as an inference when a /rustc/<40-hex>/ path is present in read-only data. Resource guards bound files to 256 MiB, dependency sections to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the compressed size, and the complete report to 8 MiB. Malformed, unsupported and oversized inputs receive typed failures rather than partial success.", inputSchema: inspectRustBinaryRequestSchema, outputSchema: evidenceResultOf( z.union([ diff --git a/src/domain/analysisView/goBinaryView.ts b/src/domain/analysisView/goBinaryView.ts index 7e43774d29..450f987317 100644 --- a/src/domain/analysisView/goBinaryView.ts +++ b/src/domain/analysisView/goBinaryView.ts @@ -1,3 +1,4 @@ +import { metadataReportSubjectFormat } from "../apple/machoTargetSelection.js"; import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { @@ -75,6 +76,7 @@ export const goBinarySummarySchema = z.strictObject({ architecture: goBinarySchema.shape.architecture, bits: goBinarySchema.shape.bits, byte_order: goBinarySchema.shape.byte_order, + selected_slice: goBinarySchema.shape.selected_slice, build_info: goBuildInfoSchema .omit({ go_version_bytes_base64: true, @@ -145,8 +147,7 @@ export const projectGoBinaryView = ( if ( parent.subject?.local_path !== report.artifact.path || parent.subject.digest.sha256 !== report.artifact.sha256 || - parent.subject.format !== - (report.format === "macho" ? "mach-o" : report.format) || + parent.subject.format !== metadataReportSubjectFormat(report) || parent.subject.architecture !== (goArchitectureForSubject(report.architecture) ?? null) ) @@ -212,6 +213,9 @@ export const projectGoBinaryView = ( architecture: report.architecture, bits: report.bits, byte_order: report.byte_order, + ...(report.selected_slice === undefined + ? {} + : { selected_slice: report.selected_slice }), build_info: info === null ? null diff --git a/src/domain/analysisView/rustBinaryView.ts b/src/domain/analysisView/rustBinaryView.ts index 5402c0c67e..2c4c1ac9f0 100644 --- a/src/domain/analysisView/rustBinaryView.ts +++ b/src/domain/analysisView/rustBinaryView.ts @@ -1,3 +1,4 @@ +import { metadataReportSubjectFormat } from "../apple/machoTargetSelection.js"; import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { @@ -30,6 +31,7 @@ export const rustBinarySummarySchema = z.strictObject({ architecture: rustBinarySchema.shape.architecture, bits: rustBinarySchema.shape.bits, byte_order: rustBinarySchema.shape.byte_order, + selected_slice: rustBinarySchema.shape.selected_slice, auditable_metadata: rustAuditableMetadataSchema .omit({ packages: true, @@ -108,6 +110,9 @@ const summarize = ( architecture: report.architecture, bits: report.bits, byte_order: report.byte_order, + ...(report.selected_slice === undefined + ? {} + : { selected_slice: report.selected_slice }), bytes: artifact.bytes, auditable_metadata: section, rustc_commit: @@ -143,7 +148,7 @@ export const projectRustBinaryView = ( subject == null || subject.local_path !== report.artifact.path || subject.digest.sha256 !== report.artifact.sha256 || - subject.format !== (report.format === "macho" ? "mach-o" : report.format) || + subject.format !== metadataReportSubjectFormat(report) || subject.architecture !== (rustArchitectureForSubject(report.architecture) ?? null) ) diff --git a/src/domain/apple/machoTargetSelection.ts b/src/domain/apple/machoTargetSelection.ts index f492bc2a0a..565c2d1f52 100644 --- a/src/domain/apple/machoTargetSelection.ts +++ b/src/domain/apple/machoTargetSelection.ts @@ -44,20 +44,44 @@ export const profileMachoTargetSelection = ( return parsed.success ? parsed.data : undefined; }; +/** mach/machine.h CPU types of the families a Mach-O slice can be selected by. */ +export const MACHO_CPU_TYPES = { + x86: 7, + x86_64: 0x01000007, + arm: 12, + arm64: 0x0100000c, +} as const satisfies Partial>; + +/** Caller-selected CPU family of one universal Mach-O slice. */ +export const machoSliceArchitectureSchema = z + .enum(["x86", "x86_64", "arm", "arm64"]) + .describe( + "CPU family of the slice to read from a universal (FAT) Mach-O. Required for universal files and rejected for every other image.", + ) satisfies z.ZodType; +export type MachoSliceArchitecture = z.infer< + typeof machoSliceArchitectureSchema +>; + /** Confirm the selected header's CPU family agrees with the caller's request. */ export const machoTargetSelectionMatchesArchitecture = ( selection: MachoTargetSelection, architecture: BinaryArchitecture, ): boolean => { - const cpuType: Partial> = { - x86: 7, - x86_64: 0x01000007, - arm: 12, - arm64: 0x0100000c, - }; - return cpuType[architecture] === selection.cpu_type; + const cpuTypes: Partial> = MACHO_CPU_TYPES; + return cpuTypes[architecture] === selection.cpu_type; }; +/** Whether a file-offset range lies inside the selected image and its file. */ +export const machoSelectionContainsRange = ( + selection: MachoTargetSelection, + fileBytes: number, + offset: number, + bytes: number, +): boolean => + selection.offset + selection.byte_length <= fileBytes && + offset >= selection.offset && + bytes <= selection.offset + selection.byte_length - offset; + /** Confirm the parser-observed container agrees with the target's declared format. */ export const machoTargetSelectionMatchesFormat = ( selection: MachoTargetSelection, @@ -77,3 +101,28 @@ export const sameMachoTargetSelection = ( left.cpu_type === right.cpu_type && left.cpu_subtype === right.cpu_subtype && left.container_table_sha256 === right.container_table_sha256; + +/** Evidence subject format of a metadata report that read an ELF, PE, thin Mach-O or universal slice. */ +export const metadataReportSubjectFormat = (report: { + readonly format: "elf" | "pe" | "macho"; + readonly selected_slice?: MachoTargetSelection; +}): "elf" | "pe" | "mach-o" | "mach-o-universal" => + report.format !== "macho" + ? report.format + : report.selected_slice === undefined + ? "mach-o" + : "mach-o-universal"; + +/** + * Whether a metadata report's own container and CPU agree with the universal + * slice it says it read: the report must be Mach-O and name the slice's CPU family. + */ +export const selectedSliceAgreesWithReport = ( + selection: MachoTargetSelection, + format: "elf" | "pe" | "macho", + architecture: BinaryArchitecture | undefined, +): boolean => + selection.container === "fat" && + format === "macho" && + architecture !== undefined && + machoTargetSelectionMatchesArchitecture(selection, architecture); diff --git a/src/domain/go/goBinary.ts b/src/domain/go/goBinary.ts index a14c50d840..b8e2de0c1b 100644 --- a/src/domain/go/goBinary.ts +++ b/src/domain/go/goBinary.ts @@ -1,5 +1,11 @@ import { z } from "zod"; import { BINARY_ARCHITECTURES } from "../binaryTargetTypes.js"; +import { + machoSelectionContainsRange, + selectedSliceAgreesWithReport, + machoSliceArchitectureSchema, + machoTargetSelectionSchema, +} from "../apple/machoTargetSelection.js"; import { digestSchema } from "../digests.js"; import { absoluteLocalPathStringSchema, @@ -52,6 +58,7 @@ export const inspectGoBinaryInputSchema = z.strictObject({ path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to the selected executable or library", ), + architecture: machoSliceArchitectureSchema.exactOptional(), detail: z .enum(["complete", "summary"]) .default("complete") @@ -85,11 +92,48 @@ export const goBinarySchema = z architecture: z.string(), bits: z.union([z.literal(32), z.literal(64)]), byte_order: z.enum(["little", "big"]), + /** The universal slice that was read; source ranges stay file offsets inside it. */ + selected_slice: machoTargetSelectionSchema.exactOptional(), build_info: goBuildInfoSchema.nullable(), limitations: z.array(z.string()), }) .superRefine((report, context) => { const info = report.build_info; + const slice = report.selected_slice; + if ( + slice !== undefined && + (!selectedSliceAgreesWithReport( + slice, + report.format, + goArchitectureForSubject(report.architecture), + ) || + !machoSelectionContainsRange( + slice, + report.artifact.bytes, + slice.offset, + 0, + ) || + (info !== null && + [ + { offset: info.header_offset, bytes: 32 }, + info.version_location, + info.module_location, + ].some( + (range) => + !machoSelectionContainsRange( + slice, + report.artifact.bytes, + range.offset, + range.bytes, + ), + ))) + ) + context.addIssue({ + code: "custom", + path: ["selected_slice"], + message: + "Selected universal slice must be a FAT slice of the artifact whose CPU matches the report's Mach-O architecture and that contains the build-info source ranges.", + }); if (info === null) return; if (info.header_offset > report.artifact.bytes - 32) context.addIssue({ diff --git a/src/domain/rust/rustBinary.ts b/src/domain/rust/rustBinary.ts index f8d0136d09..6bd327aa32 100644 --- a/src/domain/rust/rustBinary.ts +++ b/src/domain/rust/rustBinary.ts @@ -5,6 +5,12 @@ import { localPathStringSchema, } from "../localPath.js"; import { BINARY_ARCHITECTURES } from "../binaryTargetTypes.js"; +import { + machoSelectionContainsRange, + selectedSliceAgreesWithReport, + machoSliceArchitectureSchema, + machoTargetSelectionSchema, +} from "../apple/machoTargetSelection.js"; /** Preserve the format owner's architecture spelling separately from Evidence vocabulary. */ export const rustArchitectureForSubject = (architecture: string) => { @@ -74,6 +80,7 @@ export const inspectRustBinaryInputSchema = z.strictObject({ path: absoluteLocalPathStringSchema.describe( "Absolute filesystem path to the selected executable or library", ), + architecture: machoSliceArchitectureSchema.exactOptional(), }); /** Bounded cargo-auditable observation. A missing section is null, not an empty dependency list. */ @@ -88,6 +95,8 @@ export const rustBinarySchema = z architecture: z.string(), bits: z.union([z.literal(32), z.literal(64)]), byte_order: z.enum(["little", "big"]), + /** The universal slice that was read; file offsets stay inside it. */ + selected_slice: machoTargetSelectionSchema.exactOptional(), auditable_metadata: rustAuditableMetadataSchema.nullable(), rustc_commit: rustcCommitSchema.nullable(), limitations: z.array(z.string()), @@ -99,6 +108,7 @@ export const rustBinarySchema = z checkPackages(metadata, context); } checkRustc(report.artifact.bytes, report.rustc_commit, context); + checkSlice(report, context); }); export type RustPackage = z.infer; @@ -295,6 +305,47 @@ const dependencyComponentInCycle = ( return only !== undefined && (edges[only]?.includes(only) ?? false); }; +const checkSlice = (report: RustBinary, context: z.RefinementCtx): void => { + const slice = report.selected_slice; + if (slice === undefined) return; + const ranges = [ + { offset: slice.offset, bytes: 0 }, + ...(report.auditable_metadata === null + ? [] + : [ + { + offset: report.auditable_metadata.file_offset, + bytes: report.auditable_metadata.section_bytes, + }, + ]), + ...(report.rustc_commit?.hashes.map(({ file_offset: offset }) => ({ + offset, + bytes: 48, + })) ?? []), + ]; + if ( + !selectedSliceAgreesWithReport( + slice, + report.format, + rustArchitectureForSubject(report.architecture), + ) || + ranges.some( + ({ offset, bytes }) => + !machoSelectionContainsRange( + slice, + report.artifact.bytes, + offset, + bytes, + ), + ) + ) + issue( + context, + ["selected_slice"], + "Selected universal slice must be a FAT slice of the artifact whose CPU matches the report's Mach-O architecture and that contains the reported file offsets.", + ); +}; + const checkRustc = ( fileBytes: number, commit: RustBinary["rustc_commit"], diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index 25457d1c21..838eb16c70 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -30,7 +30,8 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; -import { readGoBinaryImage } from "./GoBinaryImage.js"; +import { readGoBinaryImage, type GoBinaryImage } from "./GoBinaryImage.js"; +import { requireSelectedNativeImage } from "../native/metadata/MachoSliceSelection.js"; import { BinaryFormatFailure, BinaryResourceFailure, @@ -195,6 +196,26 @@ const limitationsFor = ( : []), ]; +/** Locate image-relative build-info ranges in the selected file. */ +const inFile = ( + info: GoBinaryImage["build_info"], + fileOffset: number, +): GoBinaryImage["build_info"] => + info === null || fileOffset === 0 + ? info + : { + ...info, + header_offset: info.header_offset + fileOffset, + version_location: { + ...info.version_location, + offset: info.version_location.offset + fileOffset, + }, + module_location: { + ...info.module_location, + offset: info.module_location.offset + fileOffset, + }, + }; + /** Stable in-process metadata inspection without subprocesses, temporary files or Go installation. */ export class GoBinaryProvider implements GoBinaryPort { readonly identity = GO_BINARY_PROVIDER_IDENTITY; @@ -235,8 +256,14 @@ export class GoBinaryProvider implements GoBinaryPort { this.#resources, ); assertArtifactReadActive(options?.signal); - const image = readGoBinaryImage(snapshot.bytes); - const info = image.build_info; + const selected = requireSelectedNativeImage( + OPERATION, + input.path, + snapshot.bytes, + input.architecture, + ); + const image = readGoBinaryImage(selected.bytes); + const info = inFile(image.build_info, selected.fileOffset); if ( info !== null && ((info.module_text !== null && @@ -262,6 +289,9 @@ export class GoBinaryProvider implements GoBinaryPort { sha256: snapshot.sha256, bytes: snapshot.bytes.length, }, + ...(selected.selection === null + ? {} + : { selected_slice: selected.selection }), build_info: info === null ? null : { ...info, module }, limitations: limitationsFor(info, module), }; diff --git a/src/native/metadata/MachoSliceSelection.ts b/src/native/metadata/MachoSliceSelection.ts new file mode 100644 index 0000000000..cbc6e37fb5 --- /dev/null +++ b/src/native/metadata/MachoSliceSelection.ts @@ -0,0 +1,149 @@ +import { createHash } from "node:crypto"; + +import { + classifyFatContainerIdentity, + readFatContainerHeader, + readMachoHeaderPrefixFacts, + validateFatSlice, +} from "../../domain/apple/machoContainer.js"; +import { + MACHO_CPU_TYPES, + type MachoSliceArchitecture, + type MachoTargetSelection, +} from "../../domain/apple/machoTargetSelection.js"; +import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +const cpuName = (cpuType: number, cpuSubtype: number): string => { + const name = Object.entries(MACHO_CPU_TYPES).find( + ([, type]) => type === cpuType, + )?.[0]; + const subtype = `0x${cpuSubtype.toString(16)}`; + return name === undefined + ? `cpu type 0x${cpuType.toString(16)} subtype ${subtype}` + : `${name} (subtype ${subtype})`; +}; + +/** The one thin image an inspector reads, and where it lies in the selected file. */ +export interface SelectedNativeImage { + readonly bytes: Buffer; + /** File offset of `bytes`; add it to image-relative offsets to locate them in the file. */ + readonly fileOffset: number; + /** Selected universal slice identity; null when the file is not a universal Mach-O. */ + readonly selection: MachoTargetSelection | null; +} + +/** Why no image could be selected; `architecture` issues are the caller's to correct. */ +export interface MachoSliceSelectionIssue { + readonly kind: "architecture" | "malformed"; + readonly message: string; +} + +/** + * Select the thin image to inspect without lipo. A universal Mach-O needs the + * caller's architecture and yields exactly one validated slice; any other file + * is returned whole and rejects an architecture rather than ignoring it. + */ +export const selectNativeImage = ( + bytes: Buffer, + architecture: MachoSliceArchitecture | undefined, +): Result => { + const fat = readFatContainerHeader(bytes, bytes.length); + const identity = + fat.state === "parsed" + ? classifyFatContainerIdentity(fat, (offset) => + offset + 4 > bytes.length ? null : bytes.readUInt32BE(offset), + ) + : undefined; + // A table whose slices declare known Mach-O CPUs but cannot be read is a + // damaged universal file; no architecture selection can repair it. + if (identity?.state === "malformed") + return err({ kind: "malformed", message: identity.reason }); + // A FAT-shaped table is not a universal Mach-O until its slices prove it; + // JVM class files share the magic. Leave such files to the image reader. + if ( + fat.state === "not-fat" || + (fat.state === "parsed" && identity?.state !== "mach-o") + ) + return architecture === undefined + ? ok({ bytes, fileOffset: 0, selection: null }) + : err({ + kind: "architecture", + message: `architecture ${architecture} was selected, but the file is not a universal Mach-O; omit architecture.`, + }); + if (fat.state !== "parsed") + return err({ + kind: "malformed", + message: + fat.state === "malformed" + ? fat.reason + : "Universal Mach-O header is truncated", + }); + const available = fat.slices + .map(({ cpuType, cpuSubtype }) => cpuName(cpuType, cpuSubtype)) + .join(", "); + if (architecture === undefined) + return err({ + kind: "architecture", + message: `Universal Mach-O holds ${available}; select one architecture.`, + }); + const matches = fat.slices.filter( + ({ cpuType }) => cpuType === MACHO_CPU_TYPES[architecture], + ); + const [slice] = matches; + if (slice === undefined || matches.length > 1) + return err({ + kind: "architecture", + message: + slice === undefined + ? `Universal Mach-O holds ${available}, not ${architecture}.` + : `Universal Mach-O holds several ${architecture} slices (${available}); no slice was selected.`, + }); + const image = bytes.subarray(slice.offset, slice.offset + slice.size); + const embedded = readMachoHeaderPrefixFacts(image).identity; + const issue = + embedded === null + ? `FAT architecture ${String(slice.index)} does not begin with a Mach-O header` + : validateFatSlice(slice, bytes.length, fat.tableEnd, embedded); + if (issue !== null) return err({ kind: "malformed", message: issue }); + return ok({ + bytes: image, + fileOffset: slice.offset, + selection: { + kind: "macho-selected-image-v1", + container: "fat", + offset: slice.offset, + byte_length: slice.size, + cpu_type: slice.cpuType, + cpu_subtype: slice.cpuSubtype, + container_table_sha256: createHash("sha256") + .update(bytes.subarray(0, fat.tableEnd)) + .digest("hex"), + }, + }); +}; + +/** + * Select the image for one inspection, or fail with the input to correct: + * `architecture` for a missing or mismatched selection, `path` for a malformed + * universal container. + */ +export const requireSelectedNativeImage = ( + operation: string, + path: string, + bytes: Buffer, + architecture: MachoSliceArchitecture | undefined, +): SelectedNativeImage => { + const selected = selectNativeImage(bytes, architecture); + if (selected.ok) return selected.value; + const { kind, message } = selected.error; + throw new AnalysisInputError(operation, undefined, [ + kind === "architecture" + ? { path: ["architecture"], reason: "invalid_value", message } + : { + path: ["path"], + reason: "invalid_format", + message: `Selected artifact ${path}: ${message}`, + }, + ]); +}; diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index 1f88804b5b..9f0de38dc9 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -29,7 +29,11 @@ import { import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { err, ok, type Result } from "../domain/result.js"; -import { readRustBinaryImage } from "./RustBinaryImage.js"; +import { + readRustBinaryImage, + type RustBinaryImage, +} from "./RustBinaryImage.js"; +import { requireSelectedNativeImage } from "../native/metadata/MachoSliceSelection.js"; import { RUST_BINARY_INPUT_BYTES, RUST_OUTPUT_BYTES, @@ -85,7 +89,16 @@ export class RustBinaryProvider implements RustBinaryPort { this.#resources, ); assertArtifactReadActive(options?.signal); - const image = readRustBinaryImage(snapshot.bytes); + const selected = requireSelectedNativeImage( + OPERATION, + input.path, + snapshot.bytes, + input.architecture, + ); + const image = inFile( + readRustBinaryImage(selected.bytes), + selected.fileOffset, + ); const report = { ...image, artifact: { @@ -93,6 +106,9 @@ export class RustBinaryProvider implements RustBinaryPort { sha256: snapshot.sha256, bytes: snapshot.bytes.length, }, + ...(selected.selection === null + ? {} + : { selected_slice: selected.selection }), limitations: limitationsFor(image), }; if ( @@ -115,6 +131,31 @@ export class RustBinaryProvider implements RustBinaryPort { } } +/** Locate image-relative section and rustc-path offsets in the selected file. */ +const inFile = (image: RustBinaryImage, fileOffset: number): RustBinaryImage => + fileOffset === 0 + ? image + : { + ...image, + auditable_metadata: + image.auditable_metadata === null + ? null + : { + ...image.auditable_metadata, + file_offset: image.auditable_metadata.file_offset + fileOffset, + }, + rustc_commit: + image.rustc_commit === null + ? null + : { + ...image.rustc_commit, + hashes: image.rustc_commit.hashes.map((entry) => ({ + ...entry, + file_offset: entry.file_offset + fileOffset, + })), + }, + }; + const outputLimit = (path: string) => new AnalysisResourceConstraintError( OPERATION, diff --git a/tests/boundary/mcp/universalSliceMcp.test.ts b/tests/boundary/mcp/universalSliceMcp.test.ts new file mode 100644 index 0000000000..9fab37a6ec --- /dev/null +++ b/tests/boundary/mcp/universalSliceMcp.test.ts @@ -0,0 +1,373 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { Client } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { goBinarySchema } from "../../../src/domain/go/goBinary.js"; +import { rustBinarySchema } from "../../../src/domain/rust/rustBinary.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createGoBinaryFixture } from "../../fixtures/go/image.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createRustImage } from "../../fixtures/rust/image.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + CPU_ARM64, + CPU_X86_64, + universalMacho, +} from "../../fixtures/universalMacho.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +const sliceTest = mcpTest.extend<{ + slices: { + client: Client; + session: ReturnType; + write: (name: string, bytes: Buffer) => Promise; + }; +}>({ + slices: async ({ mcp }, use) => { + const session = createTestBinarySession(() => { + throw new Error("Static metadata must not start a deep provider"); + }); + const root = await createTestTempDirectory("rea-universal-slice-"); + try { + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + await use({ + client, + session, + write: async (name, bytes) => { + const path = join(root, name); + await writeFile(path, bytes); + return path; + }, + }); + } finally { + await session.close(); + } + }, +}); + +const sha256 = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +const goSlices = () => { + const intel = createGoBinaryFixture({ + format: "macho", + goVersion: "go1.25.0", + }); + const arm = createGoBinaryFixture({ format: "macho", goVersion: "go1.26.1" }); + return { + arm, + universal: universalMacho([ + { bytes: intel.bytes, cpuType: CPU_X86_64, cpuSubtype: 3 }, + { bytes: arm.bytes, cpuType: CPU_ARM64, cpuSubtype: 0 }, + ]), + }; +}; + +const issues = (response: unknown) => + z + .object({ + error: z.object({ + code: z.string(), + details: z.object({ + issues: z.array( + z.object({ path: z.array(z.string()), message: z.string() }), + ), + }), + }), + }) + .parse(parseMcpToolError(response as never)).error; + +sliceTest( + "reads the selected Go slice of a universal Mach-O with file-relative provenance", + async ({ slices: { client, session, write } }) => { + const { arm, universal } = goSlices(); + const path = await write("universal-go", universal.bytes); + const advertised = (await client.listTools()).tools.find( + ({ name }) => name === "inspect_go_binary", + ); + if (advertised?.outputSchema === undefined) + throw new Error("Missing advertised Go output schema"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + expect( + ajv.compile(advertised.inputSchema)({ path, architecture: "arm64" }), + ).toBe(true); + const validateOutput = ajv.compile( + z.record(z.string(), z.unknown()).parse(advertised.outputSchema), + ); + + const response = await client.callTool({ + name: "inspect_go_binary", + arguments: { path, architecture: "arm64" }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + expect( + validateOutput(response.structuredContent), + JSON.stringify(validateOutput.errors), + ).toBe(true); + const evidence = toolContract("inspect_go_binary").outputSchema.parse( + response.structuredContent, + ); + const report = goBinarySchema.parse(evidence.normalized_result); + const [, armSlice] = universal.slices; + if (armSlice === undefined) throw new Error("Fixture lacks a slice"); + expect(report.artifact).toEqual({ + path, + sha256: sha256(universal.bytes), + bytes: universal.bytes.length, + }); + expect(report.selected_slice).toEqual({ + kind: "macho-selected-image-v1", + container: "fat", + offset: armSlice.offset, + byte_length: armSlice.size, + cpu_type: CPU_ARM64, + cpu_subtype: 0, + container_table_sha256: sha256( + universal.bytes.subarray(0, universal.tableBytes), + ), + }); + expect(report.build_info).toMatchObject({ + go_version: "go1.26.1", + header_offset: armSlice.offset + arm.headerOffset, + version_location: { offset: armSlice.offset + arm.versionOffset }, + module_location: { offset: armSlice.offset + arm.moduleOffset }, + }); + // File-relative ranges address the selected slice's bytes in the universal file. + const version = report.build_info?.version_location; + expect( + universal.bytes + .subarray( + version?.offset, + (version?.offset ?? 0) + (version?.bytes ?? 0), + ) + .toString(), + ).toBe("go1.26.1"); + expect(evidence.subject).toMatchObject({ + format: "mach-o-universal", + architecture: "arm64", + digest: { sha256: sha256(universal.bytes) }, + }); + expect(evidence.parameters).toEqual({ path, architecture: "arm64" }); + // A retained report cannot claim a slice its own container or CPU contradicts. + for (const contradiction of [ + { architecture: "amd64" }, + { format: "elf" as const }, + ]) + expect( + goBinarySchema.safeParse({ ...report, ...contradiction }).success, + ).toBe(false); + expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); + + const summary = await client.callTool({ + name: "inspect_go_binary", + arguments: { path, architecture: "x86_64", detail: "summary" }, + }); + expect(summary.isError, JSON.stringify(summary.content)).not.toBe(true); + expect(summary.structuredContent).toMatchObject({ + normalized_result: { + kind: "summary", + summary: { + selected_slice: { cpu_type: CPU_X86_64, cpu_subtype: 3 }, + build_info: { go_version: "go1.25.0" }, + }, + }, + }); + }, +); + +sliceTest( + "names the slices present when a universal selection is missing or does not match", + async ({ slices: { client, write } }) => { + const { universal } = goSlices(); + const path = await write("universal-go", universal.bytes); + const available = "x86_64 (subtype 0x3), arm64 (subtype 0x0)"; + for (const [architecture, message] of [ + [ + undefined, + `Universal Mach-O holds ${available}; select one architecture.`, + ], + ["x86", `Universal Mach-O holds ${available}, not x86.`], + ] as const) { + const response = await client.callTool({ + name: "inspect_go_binary", + arguments: { + path, + ...(architecture === undefined ? {} : { architecture }), + }, + }); + expect(issues(response)).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: ["architecture"], message }] }, + }); + } + + const thin = await write( + "thin-go", + createGoBinaryFixture({ format: "macho" }).bytes, + ); + expect( + issues( + await client.callTool({ + name: "inspect_go_binary", + arguments: { path: thin, architecture: "arm64" }, + }), + ), + ).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["architecture"], + message: + "architecture arm64 was selected, but the file is not a universal Mach-O; omit architecture.", + }, + ], + }, + }); + + const duplicate = await write( + "duplicate-arm64", + universalMacho([ + { + bytes: createGoBinaryFixture({ format: "macho" }).bytes, + cpuType: CPU_ARM64, + cpuSubtype: 0, + }, + { + bytes: createGoBinaryFixture({ format: "macho" }).bytes, + cpuType: CPU_ARM64, + cpuSubtype: 2, + }, + ]).bytes, + ); + expect( + issues( + await client.callTool({ + name: "inspect_go_binary", + arguments: { path: duplicate, architecture: "arm64" }, + }), + ).details.issues[0]?.message, + ).toBe( + "Universal Mach-O holds several arm64 slices (arm64 (subtype 0x0), arm64 (subtype 0x2)); no slice was selected.", + ); + + // A slice that runs past the file is damaged, not a selection to correct. + const truncated = universalMacho([ + { + bytes: createGoBinaryFixture({ format: "macho" }).bytes, + cpuType: CPU_ARM64, + cpuSubtype: 0, + }, + ]).bytes; + truncated.writeUInt32BE(truncated.length, 8 + 12); + const damaged = await write("damaged", truncated); + for (const architecture of [undefined, "arm64"] as const) + expect( + issues( + await client.callTool({ + name: "inspect_go_binary", + arguments: { + path: damaged, + ...(architecture === undefined ? {} : { architecture }), + }, + }), + ).details.issues[0], + ).toMatchObject({ + path: ["path"], + message: expect.stringContaining(`Selected artifact ${damaged}: FAT`), + }); + + // The FAT table and the slice header must agree before the slice is read. + const disagreeing = await write( + "disagreeing", + universalMacho([ + { + bytes: createGoBinaryFixture({ format: "macho" }).bytes, + cpuType: CPU_X86_64, + cpuSubtype: 3, + tableCpuType: CPU_ARM64, + }, + ]).bytes, + ); + expect( + issues( + await client.callTool({ + name: "inspect_go_binary", + arguments: { path: disagreeing, architecture: "arm64" }, + }), + ), + ).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["path"], + message: `Selected artifact ${disagreeing}: FAT architecture 0 CPU type/subtype disagrees with its Mach-O header`, + }, + ], + }, + }); + }, +); + +sliceTest( + "reads the selected Rust slice and keeps section and rustc offsets in the universal file", + async ({ slices: { client, write } }) => { + const thin = createRustImage({ format: "macho", includeRustc: true }); + const universal = universalMacho([ + { bytes: thin, cpuType: CPU_X86_64, cpuSubtype: 3 }, + { bytes: thin, cpuType: CPU_ARM64, cpuSubtype: 0 }, + ]); + const thinPath = await write("thin-rust", thin); + const path = await write("universal-rust", universal.bytes); + const thinResponse = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path: thinPath }, + }); + const response = await client.callTool({ + name: "inspect_rust_binary", + arguments: { path, architecture: "arm64" }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const contract = toolContract("inspect_rust_binary"); + const thinReport = rustBinarySchema.parse( + contract.outputSchema.parse(thinResponse.structuredContent) + .normalized_result, + ); + const report = rustBinarySchema.parse( + contract.outputSchema.parse(response.structuredContent).normalized_result, + ); + const offset = universal.slices[1]?.offset ?? 0; + expect(report.selected_slice).toMatchObject({ + offset, + cpu_type: CPU_ARM64, + }); + expect(report.auditable_metadata).toEqual({ + ...thinReport.auditable_metadata, + file_offset: (thinReport.auditable_metadata?.file_offset ?? 0) + offset, + }); + expect(report.rustc_commit?.hashes).toEqual( + thinReport.rustc_commit?.hashes.map((entry) => ({ + ...entry, + file_offset: entry.file_offset + offset, + })), + ); + expect( + issues( + await client.callTool({ + name: "inspect_rust_binary", + arguments: { path }, + }), + ).details.issues[0]?.path, + ).toEqual(["architecture"]); + }, +); diff --git a/tests/fixtures/universalMacho.ts b/tests/fixtures/universalMacho.ts new file mode 100644 index 0000000000..208c1257ac --- /dev/null +++ b/tests/fixtures/universalMacho.ts @@ -0,0 +1,62 @@ +/** One thin Mach-O slice and the CPU identity its header and FAT entry declare. */ +export interface UniversalSlice { + readonly bytes: Buffer; + readonly cpuType: number; + readonly cpuSubtype: number; + /** Declare a different CPU in the FAT table than in the slice header. */ + readonly tableCpuType?: number; +} + +/** The FAT32 container and where each slice landed. */ +export interface UniversalMachoFixture { + readonly bytes: Buffer; + readonly slices: readonly { + readonly offset: number; + readonly size: number; + }[]; + readonly tableBytes: number; +} + +const ALIGNMENT_EXPONENT = 12; + +/** Build a big-endian FAT32 universal Mach-O whose slices are 4 KiB aligned. */ +export const universalMacho = ( + slices: readonly UniversalSlice[], +): UniversalMachoFixture => { + const tableBytes = 8 + slices.length * 20; + const align = 2 ** ALIGNMENT_EXPONENT; + const placed: { offset: number; size: number }[] = []; + let offset = Math.ceil(tableBytes / align) * align; + for (const slice of slices) { + placed.push({ offset, size: slice.bytes.length }); + offset = Math.ceil((offset + slice.bytes.length) / align) * align; + } + const bytes = Buffer.alloc(offset); + bytes.writeUInt32BE(0xcafebabe, 0); + bytes.writeUInt32BE(slices.length, 4); + for (const [index, slice] of slices.entries()) { + const { offset: sliceOffset, size } = placed[index]!; + const entry = 8 + index * 20; + bytes.writeUInt32BE(slice.tableCpuType ?? slice.cpuType, entry); + bytes.writeUInt32BE(slice.cpuSubtype, entry + 4); + bytes.writeUInt32BE(sliceOffset, entry + 8); + bytes.writeUInt32BE(size, entry + 12); + bytes.writeUInt32BE(ALIGNMENT_EXPONENT, entry + 16); + const image = Buffer.from(slice.bytes); + const little = + image.readUInt32BE(0) === 0xcffaedfe || + image.readUInt32BE(0) === 0xcefaedfe; + if (little) { + image.writeUInt32LE(slice.cpuType, 4); + image.writeUInt32LE(slice.cpuSubtype, 8); + } else { + image.writeUInt32BE(slice.cpuType, 4); + image.writeUInt32BE(slice.cpuSubtype, 8); + } + image.copy(bytes, sliceOffset); + } + return { bytes, slices: placed, tableBytes }; +}; + +export const CPU_X86_64 = 0x01000007; +export const CPU_ARM64 = 0x0100000c;