From c8e9cc0914d6e51620bfda1303c9b2838a5007ee Mon Sep 17 00:00:00 2001 From: chordpli Date: Tue, 10 Nov 2026 17:06:40 +0900 Subject: [PATCH 1/5] fix(native): select the preferred universal slice for metadata readers inspect_native_initializers, inspect_go_binary and inspect_rust_binary matched universal Mach-O slices by CPU type only and refused files whose arm64 and arm64e slices share a CPU type, such as /usr/bin/file. Move the subtype-preference choice that inspect_macho already uses into selectPreferredMachoSlice and use it from both selectors, so arm64 resolves to one slice. Slices that share a base subtype are still refused. Fixes #9792 --- src/native/AppleMachoSelection.ts | 36 +++---- src/native/MachoArchitectureSelection.ts | 46 +++++++- src/native/metadata/MachoSliceSelection.ts | 17 +-- .../mcp/nativeInitializersMcp.test.ts | 44 ++++++++ tests/boundary/mcp/universalSliceMcp.test.ts | 100 +++++++++++++----- 5 files changed, 184 insertions(+), 59 deletions(-) diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 08f3169cbf4..9750df01c81 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -23,9 +23,8 @@ import type { FixupCommands } from "./AppleMachoFixups.js"; import { decodeMachoFixedName, decodeMachoName } from "./AppleMachoNames.js"; import { MACHO_ARCHITECTURE_POLICIES, - machoSubtypeRank, + selectPreferredMachoSlice, } from "./MachoArchitectureSelection.js"; -import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js"; import { readFatSliceDeclarations, readFatHeaderPrefix, @@ -100,8 +99,8 @@ export const selectMachoSlice = ( const { count, tableEnd: headerEnd } = header; if (count > 128 || headerEnd > bytes.length) throw malformed("Malformed FAT architecture table"); - const policy = MACHO_ARCHITECTURE_POLICIES[architecture]; - if (policy === undefined) + // An unknown architecture is refused before the declarations are parsed. + if (MACHO_ARCHITECTURE_POLICIES[architecture] === undefined) throw invalid("Requested FAT architecture is absent"); const declarations = readFatSliceDeclarations(bytes.subarray(8, headerEnd), { count, @@ -109,30 +108,19 @@ export const selectMachoSlice = ( littleEndian, }); if (declarations.status !== "parsed") throw malformed(declarations.reason); - let best: FatSliceDeclaration | undefined; - let bestRank = Number.POSITIVE_INFINITY; - let ambiguous = false; for (const declaration of declarations.slices) { const issue = validateFatSlice(declaration, bytes.length, headerEnd); if (issue !== null) throw malformed(issue); - if (declaration.cpuType !== policy.cpuType) continue; - const rank = machoSubtypeRank(policy, declaration.cpuSubtype); - if (best !== undefined && rank >= bestRank) { - // Equal ranks keep FAT table order unless the slices share a subtype. - if ( - rank === bestRank && - machoCpuSubtypeBase(declaration.cpuSubtype) === - machoCpuSubtypeBase(best.cpuSubtype) - ) - ambiguous = true; - continue; - } - best = declaration; - bestRank = rank; - ambiguous = false; } - if (best === undefined) throw invalid("Requested FAT architecture is absent"); - if (ambiguous) throw invalid("Ambiguous FAT architecture slice"); + const preferred = selectPreferredMachoSlice( + declarations.slices, + architecture, + ); + if (preferred.status === "absent") + throw invalid("Requested FAT architecture is absent"); + if (preferred.status === "ambiguous") + throw invalid("Ambiguous FAT architecture slice"); + const best = preferred.slice; const selected: MachoSlice = { slice: best.offset, sliceEnd: best.offset + best.size, diff --git a/src/native/MachoArchitectureSelection.ts b/src/native/MachoArchitectureSelection.ts index 8463faa0ecf..a916fc12600 100644 --- a/src/native/MachoArchitectureSelection.ts +++ b/src/native/MachoArchitectureSelection.ts @@ -36,7 +36,7 @@ export const MACHO_ARCHITECTURE_POLICIES: Readonly< }; /** Unlisted subtypes follow the preferred slices in FAT table order. */ -export const machoSubtypeRank = ( +const machoSubtypeRank = ( policy: MachoArchitecturePolicy, subtype: number, ): number => { @@ -46,6 +46,50 @@ export const machoSubtypeRank = ( return rank < 0 ? policy.subtypes.length : rank; }; +/** The FAT slice one CPU family resolves to, or why none can be chosen. */ +export type PreferredMachoSlice = + | { readonly status: "selected"; readonly slice: Slice } + | { readonly status: "absent" } + | { readonly status: "ambiguous" }; + +/** + * Choose the one FAT slice an architecture selects. Lower subtype ranks win; + * equal ranks keep FAT table order unless the slices share a base subtype, + * which leaves no single slice to choose. + */ +export const selectPreferredMachoSlice = < + Slice extends { readonly cpuType: number; readonly cpuSubtype: number }, +>( + slices: readonly Slice[], + architecture: string, +): PreferredMachoSlice => { + const policy = MACHO_ARCHITECTURE_POLICIES[architecture]; + if (policy === undefined) return { status: "absent" }; + let best: Slice | undefined; + let bestRank = Number.POSITIVE_INFINITY; + let ambiguous = false; + for (const slice of slices) { + if (slice.cpuType !== policy.cpuType) continue; + const rank = machoSubtypeRank(policy, slice.cpuSubtype); + if (best !== undefined && rank >= bestRank) { + if ( + rank === bestRank && + machoCpuSubtypeBase(slice.cpuSubtype) === + machoCpuSubtypeBase(best.cpuSubtype) + ) + ambiguous = true; + continue; + } + best = slice; + bestRank = rank; + ambiguous = false; + } + if (best === undefined) return { status: "absent" }; + return ambiguous + ? { status: "ambiguous" } + : { status: "selected", slice: best }; +}; + /** codesign's architecture name for one FAT CPU identity, when known. */ const codeSignArchitectureName = ( cpuType: number, diff --git a/src/native/metadata/MachoSliceSelection.ts b/src/native/metadata/MachoSliceSelection.ts index c3c525b18c6..c05bfa69937 100644 --- a/src/native/metadata/MachoSliceSelection.ts +++ b/src/native/metadata/MachoSliceSelection.ts @@ -11,6 +11,7 @@ import { } from "../../domain/apple/machoTargetSelection.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { selectPreferredMachoSlice } from "../MachoArchitectureSelection.js"; const cpuName = (cpuType: number, cpuSubtype: number): string => { const name = Object.entries(MACHO_CPU_TYPES).find( @@ -39,8 +40,10 @@ export interface MachoSliceSelectionIssue { /** * 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. + * caller's architecture and yields the one validated slice picked by the + * subtype preference order (`MACHO_ARCHITECTURE_POLICIES`) that + * `inspect_macho` also uses; any other file is returned whole and rejects an + * architecture rather than ignoring it. */ export const selectNativeImage = ( bytes: Buffer, @@ -64,18 +67,16 @@ export const selectNativeImage = ( 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) + const preferred = selectPreferredMachoSlice(fat.slices, architecture); + if (preferred.status !== "selected") return err({ kind: "architecture", message: - slice === undefined + preferred.status === "absent" ? `Universal Mach-O holds ${available}, not ${architecture}.` : `Universal Mach-O holds several ${architecture} slices (${available}); no slice was selected.`, }); + const { slice } = preferred; const selected = readUniversalMachoSlice(bytes, fat, slice); if (!selected.ok) return selected; return ok({ diff --git a/tests/boundary/mcp/nativeInitializersMcp.test.ts b/tests/boundary/mcp/nativeInitializersMcp.test.ts index 9b2c504e5cc..c4a5ed708aa 100644 --- a/tests/boundary/mcp/nativeInitializersMcp.test.ts +++ b/tests/boundary/mcp/nativeInitializersMcp.test.ts @@ -7,9 +7,15 @@ import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { machoArm64FatInitializers, + machoArm64Initializers, elf64PieInitializers, } from "../../../src/native/initializers/NativeInitializers.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + CPU_ARM64, + CPU_X86_64, + universalMacho, +} from "../../fixtures/universalMacho.js"; import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js"; const connect = async (mcp: TestMcp) => { @@ -89,6 +95,44 @@ mcpTest( }, ); +mcpTest( + "reads the preferred arm64 slice when a universal Mach-O also holds arm64e", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + // The x86_64, arm64 and arm64e layout of stock tools such as /usr/bin/file. + const thin = machoArm64Initializers(); + const universal = universalMacho([ + { bytes: thin, cpuType: CPU_X86_64, cpuSubtype: 3 }, + { bytes: thin, cpuType: CPU_ARM64, cpuSubtype: 0 }, + { bytes: thin, cpuType: CPU_ARM64, cpuSubtype: 0x80000002 }, + ]); + const root = await createTestTempDirectory("rea-native-init-arm64e-mcp-"); + const path = join(root, "universal"); + await writeFile(path, universal.bytes); + const response = await client.callTool({ + name: "inspect_native_initializers", + arguments: { path, architecture: "arm64" }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = toolContract( + "inspect_native_initializers", + ).outputSchema.parse(response.structuredContent); + expect(evidence.normalized_result).toMatchObject({ + format: "mach-o", + slice: { + file_offset: universal.slices[1]?.offset, + cpu_subtype: 0, + architecture: "arm64", + }, + }); + expect(evidence.normalized_result.entries.length).toBeGreaterThan(0); + } finally { + await session.close(); + } + }, +); + mcpTest( "requires an absolute path for inspect_native_initializers", async ({ mcp }) => { diff --git a/tests/boundary/mcp/universalSliceMcp.test.ts b/tests/boundary/mcp/universalSliceMcp.test.ts index c91e0304181..ddc60a87222 100644 --- a/tests/boundary/mcp/universalSliceMcp.test.ts +++ b/tests/boundary/mcp/universalSliceMcp.test.ts @@ -235,32 +235,6 @@ sliceTest( }, }); - 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([ { @@ -320,6 +294,80 @@ sliceTest( }, ); +sliceTest( + "selects the preferred subtype when several slices share a CPU family", + async ({ slices: { client, write } }) => { + // Stock macOS tools ship arm64 and arm64e together; arm64 selects the + // preferred subtype wherever it sits in the FAT table. + const preferred = universalMacho([ + { + bytes: createGoBinaryFixture({ + format: "macho", + goVersion: "go1.25.0", + }).bytes, + cpuType: CPU_ARM64, + cpuSubtype: 0x80000002, + }, + { + bytes: createGoBinaryFixture({ + format: "macho", + goVersion: "go1.26.1", + }).bytes, + cpuType: CPU_ARM64, + cpuSubtype: 0, + }, + ]); + const mixed = await client.callTool({ + name: "inspect_go_binary", + arguments: { + path: await write("arm64-and-arm64e", preferred.bytes), + architecture: "arm64", + }, + }); + expect(mixed.isError, JSON.stringify(mixed.content)).not.toBe(true); + expect( + goBinarySchema.parse( + toolContract("inspect_go_binary").outputSchema.parse( + mixed.structuredContent, + ).normalized_result, + ), + ).toMatchObject({ + selected_slice: { + offset: preferred.slices[1]?.offset, + cpu_subtype: 0, + }, + build_info: { go_version: "go1.26.1" }, + }); + + // Slices that share a base subtype leave no single slice to choose. + 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: 0, + }, + ]).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 0x0)); no slice was selected.", + ); + }, +); + sliceTest( "reads the selected Rust slice and keeps section and rustc offsets in the universal file", async ({ slices: { client, write } }) => { From 42baa4317bb696ac030baf3f61eb1b7377a028ad Mon Sep 17 00:00:00 2001 From: chordpli Date: Tue, 10 Nov 2026 17:20:15 +0900 Subject: [PATCH 2/5] docs(native): describe preferred slice selection for universal Mach-O readers --- docs/guides/native/go-binaries.md | 7 +++++-- docs/guides/native/native-initializers.md | 6 +++++- docs/rust-binaries.md | 7 +++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/guides/native/go-binaries.md b/docs/guides/native/go-binaries.md index 21856434550..e3c8720c78a 100644 --- a/docs/guides/native/go-binaries.md +++ b/docs/guides/native/go-binaries.md @@ -166,8 +166,11 @@ 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. +such slice, the request fails on `architecture` and lists the slices present. +When several slices share the CPU family, REA reads the one `inspect_macho` +would select: `arm64` before `arm64e` before `arm64v8`, otherwise the first in +FAT table order. Slices that share a base subtype still fail on `architecture`. +`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/guides/native/native-initializers.md b/docs/guides/native/native-initializers.md index 281c548520a..f2c3277ce97 100644 --- a/docs/guides/native/native-initializers.md +++ b/docs/guides/native/native-initializers.md @@ -20,7 +20,11 @@ requires an absolute filesystem path: } ``` -A universal Mach-O requires `architecture` to select one slice. ELF files and +A universal Mach-O requires `architecture` to select one slice. When several +slices share the CPU family, such as the `arm64` and `arm64e` slices of stock +macOS tools, REA reads the one `inspect_macho` would select (`arm64` before +`arm64e` before `arm64v8`, otherwise FAT table order) and reports it in +`slice`. Slices that share a base subtype fail on `architecture`. ELF files and thin Mach-O files do not take it. ## Sources diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md index 967e3b8c5c3..b606b4dfebb 100644 --- a/docs/rust-binaries.md +++ b/docs/rust-binaries.md @@ -168,8 +168,11 @@ 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. +such slice, the request fails on `architecture` and lists the slices present. +When several slices share the CPU family, REA reads the one `inspect_macho` +would select: `arm64` before `arm64e` before `arm64v8`, otherwise the first in +FAT table order. Slices that share a base subtype still fail on `architecture`. +`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 From 5723134559aa00b45b70f33e92ba662310f4c4a5 Mon Sep 17 00:00:00 2001 From: chordpli Date: Tue, 10 Nov 2026 17:28:01 +0900 Subject: [PATCH 3/5] fix(native): report unread same-family slices as a limitation When a universal Mach-O holds several slices of the selected CPU family, the metadata readers now name the slices they did not read in limitations. Correct the guides' description of the preference order, add selector unit cases for x86_64h and 32-bit ARM ordering, and mention the preference in the architecture schema. --- docs/guides/native/go-binaries.md | 6 ++- docs/guides/native/native-initializers.md | 6 ++- docs/rust-binaries.md | 6 ++- src/domain/apple/machoTargetSelection.ts | 2 +- src/go/GoBinaryProvider.ts | 15 +++--- src/native/MachoArchitectureSelection.test.ts | 49 +++++++++++++++++++ .../NativeInitializersProvider.ts | 6 ++- src/native/metadata/MachoSliceSelection.ts | 16 +++++- src/rust/RustBinaryProvider.ts | 2 +- .../mcp/nativeInitializersMcp.test.ts | 4 ++ tests/boundary/mcp/universalSliceMcp.test.ts | 17 ++++--- 11 files changed, 106 insertions(+), 23 deletions(-) create mode 100644 src/native/MachoArchitectureSelection.test.ts diff --git a/docs/guides/native/go-binaries.md b/docs/guides/native/go-binaries.md index e3c8720c78a..90efd993edd 100644 --- a/docs/guides/native/go-binaries.md +++ b/docs/guides/native/go-binaries.md @@ -168,8 +168,10 @@ 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, the request fails on `architecture` and lists the slices present. When several slices share the CPU family, REA reads the one `inspect_macho` -would select: `arm64` before `arm64e` before `arm64v8`, otherwise the first in -FAT table order. Slices that share a base subtype still fail on `architecture`. +would select: `arm64` before `arm64e` before `arm64v8`, `x86_64` before other +x86_64 subtypes such as `x86_64h`, and FAT table order among subtypes with no +listed preference. `limitations` names the same-family slices it did not read. +Slices that share a base subtype still fail on `architecture`. `architecture` on any other image is rejected rather than ignored. For ELF images without a named build-info section, discovery checks each diff --git a/docs/guides/native/native-initializers.md b/docs/guides/native/native-initializers.md index f2c3277ce97..17577697bc2 100644 --- a/docs/guides/native/native-initializers.md +++ b/docs/guides/native/native-initializers.md @@ -23,8 +23,10 @@ requires an absolute filesystem path: A universal Mach-O requires `architecture` to select one slice. When several slices share the CPU family, such as the `arm64` and `arm64e` slices of stock macOS tools, REA reads the one `inspect_macho` would select (`arm64` before -`arm64e` before `arm64v8`, otherwise FAT table order) and reports it in -`slice`. Slices that share a base subtype fail on `architecture`. ELF files and +`arm64e` before `arm64v8`, `x86_64` before `x86_64h`, and FAT table order among +subtypes with no listed preference) and reports it in `slice`. `limitations` +names the same-family slices it did not read. Slices that share a base subtype +fail on `architecture`. ELF files and thin Mach-O files do not take it. ## Sources diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md index b606b4dfebb..07fd71b474d 100644 --- a/docs/rust-binaries.md +++ b/docs/rust-binaries.md @@ -170,8 +170,10 @@ type/subtype and the FAT table digest, and reports the section and rustc-path of file offset inside that slice. Without `architecture`, or when the file has no such slice, the request fails on `architecture` and lists the slices present. When several slices share the CPU family, REA reads the one `inspect_macho` -would select: `arm64` before `arm64e` before `arm64v8`, otherwise the first in -FAT table order. Slices that share a base subtype still fail on `architecture`. +would select: `arm64` before `arm64e` before `arm64v8`, `x86_64` before other +x86_64 subtypes such as `x86_64h`, and FAT table order among subtypes with no +listed preference. `limitations` names the same-family slices it did not read. +Slices that share a base subtype still fail on `architecture`. `architecture` on any other image is rejected rather than ignored. Package and dependency counts are checked against the minimum required JSON diff --git a/src/domain/apple/machoTargetSelection.ts b/src/domain/apple/machoTargetSelection.ts index 565c2d1f52d..e53856e159e 100644 --- a/src/domain/apple/machoTargetSelection.ts +++ b/src/domain/apple/machoTargetSelection.ts @@ -56,7 +56,7 @@ export const MACHO_CPU_TYPES = { 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.", + "CPU family of the slice to read from a universal (FAT) Mach-O. Required for universal files and rejected for every other image. When several slices share the family, the preferred subtype is read (arm64 before arm64e).", ) satisfies z.ZodType; export type MachoSliceArchitecture = z.infer< typeof machoSliceArchitectureSchema diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index 9404a04a214..8254d1c55f9 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -331,12 +331,15 @@ export class GoBinaryProvider implements GoBinaryPort { ...(stringLiterals === undefined ? {} : { string_literals: stringLiterals }), - limitations: limitationsFor( - info, - sectionNamesLimitation, - stringLiterals, - machoChainedFixups, - ), + limitations: [ + ...limitationsFor( + info, + sectionNamesLimitation, + stringLiterals, + machoChainedFixups, + ), + ...selected.limitations, + ], }; if (Buffer.byteLength(JSON.stringify(report), "utf8") > OUTPUT_BYTES) throw outputLimit(input.path); diff --git a/src/native/MachoArchitectureSelection.test.ts b/src/native/MachoArchitectureSelection.test.ts new file mode 100644 index 00000000000..a368c9d8114 --- /dev/null +++ b/src/native/MachoArchitectureSelection.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { selectPreferredMachoSlice } from "./MachoArchitectureSelection.js"; + +const CPU_ARM = 12; +const CPU_X86_64 = 0x01000007; +const CPU_ARM64 = 0x0100000c; +const slice = (index: number, cpuType: number, cpuSubtype: number) => ({ + index, + cpuType, + cpuSubtype, +}); + +describe("selectPreferredMachoSlice", () => { + it("prefers listed subtypes over FAT table order", () => { + const x86 = [slice(0, CPU_X86_64, 8), slice(1, CPU_X86_64, 3)]; + expect(selectPreferredMachoSlice(x86, "x86_64")).toEqual({ + status: "selected", + slice: x86[1], + }); + const arm64 = [slice(0, CPU_ARM64, 0x80000002), slice(1, CPU_ARM64, 0)]; + expect(selectPreferredMachoSlice(arm64, "arm64")).toEqual({ + status: "selected", + slice: arm64[1], + }); + }); + + it("keeps FAT table order among subtypes with no listed preference", () => { + // armv7s (11) before armv7 (9): the arm policy lists no subtype order. + const arm = [slice(0, CPU_ARM, 11), slice(1, CPU_ARM, 9)]; + expect(selectPreferredMachoSlice(arm, "arm")).toEqual({ + status: "selected", + slice: arm[0], + }); + }); + + it("refuses slices that share a base subtype and reports absent families", () => { + const duplicate = [slice(0, CPU_ARM64, 0), slice(1, CPU_ARM64, 0)]; + expect(selectPreferredMachoSlice(duplicate, "arm64")).toEqual({ + status: "ambiguous", + }); + expect(selectPreferredMachoSlice(duplicate, "x86_64")).toEqual({ + status: "absent", + }); + expect(selectPreferredMachoSlice(duplicate, "sparc")).toEqual({ + status: "absent", + }); + }); +}); diff --git a/src/native/initializers/NativeInitializersProvider.ts b/src/native/initializers/NativeInitializersProvider.ts index 9386f327b2c..8391dd3088f 100644 --- a/src/native/initializers/NativeInitializersProvider.ts +++ b/src/native/initializers/NativeInitializersProvider.ts @@ -129,7 +129,11 @@ export const readNativeInitializers = async ( ); const page = { offset: input.offset, limit: input.limit }; const inspection = await inspectSelected(bytes, selected, page, signal); - return { artifact, ...inspection }; + return { + artifact, + ...inspection, + limitations: [...inspection.limitations, ...selected.limitations], + }; }; const inspectSelected = async ( diff --git a/src/native/metadata/MachoSliceSelection.ts b/src/native/metadata/MachoSliceSelection.ts index c05bfa69937..462eb101036 100644 --- a/src/native/metadata/MachoSliceSelection.ts +++ b/src/native/metadata/MachoSliceSelection.ts @@ -30,6 +30,8 @@ export interface SelectedNativeImage { readonly fileOffset: number; /** Selected universal slice identity; null when the file is not a universal Mach-O. */ readonly selection: MachoTargetSelection | null; + /** Other slices of the selected CPU family that this inspection does not read. */ + readonly limitations: readonly string[]; } /** Why no image could be selected; `architecture` issues are the caller's to correct. */ @@ -54,7 +56,7 @@ export const selectNativeImage = ( const fat = container.value; if (fat === null) return architecture === undefined - ? ok({ bytes, fileOffset: 0, selection: null }) + ? ok({ bytes, fileOffset: 0, selection: null, limitations: [] }) : err({ kind: "architecture", message: `architecture ${architecture} was selected, but the file is not a universal Mach-O; omit architecture.`, @@ -79,6 +81,12 @@ export const selectNativeImage = ( const { slice } = preferred; const selected = readUniversalMachoSlice(bytes, fat, slice); if (!selected.ok) return selected; + const unread = fat.slices + .filter((other) => other !== slice && other.cpuType === slice.cpuType) + .map( + (other) => + `${cpuName(other.cpuType, other.cpuSubtype)} at file offset ${String(other.offset)}`, + ); return ok({ bytes: selected.value, fileOffset: slice.offset, @@ -93,6 +101,12 @@ export const selectNativeImage = ( .update(bytes.subarray(0, fat.tableEnd)) .digest("hex"), }, + limitations: + unread.length === 0 + ? [] + : [ + `Only the preferred ${architecture} slice (${cpuName(slice.cpuType, slice.cpuSubtype)} at file offset ${String(slice.offset)}) was read; this universal Mach-O also holds ${unread.join(", ")}, which no architecture value selects.`, + ], }); }; diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index c993d5b0032..a53c269eaf0 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -110,7 +110,7 @@ export class RustBinaryProvider implements RustBinaryPort { ...(selected.selection === null ? {} : { selected_slice: selected.selection }), - limitations: limitationsFor(image), + limitations: [...limitationsFor(image), ...selected.limitations], }; if ( Buffer.byteLength(JSON.stringify(report), "utf8") > RUST_OUTPUT_BYTES diff --git a/tests/boundary/mcp/nativeInitializersMcp.test.ts b/tests/boundary/mcp/nativeInitializersMcp.test.ts index c4a5ed708aa..6dead44d87d 100644 --- a/tests/boundary/mcp/nativeInitializersMcp.test.ts +++ b/tests/boundary/mcp/nativeInitializersMcp.test.ts @@ -127,6 +127,10 @@ mcpTest( }, }); expect(evidence.normalized_result.entries.length).toBeGreaterThan(0); + // The arm64e slice is not read, and the result says so. + expect(evidence.normalized_result.limitations).toContain( + `Only the preferred arm64 slice (arm64 (subtype 0x0) at file offset ${String(universal.slices[1]?.offset)}) was read; this universal Mach-O also holds arm64 (subtype 0x80000002) at file offset ${String(universal.slices[2]?.offset)}, which no architecture value selects.`, + ); } finally { await session.close(); } diff --git a/tests/boundary/mcp/universalSliceMcp.test.ts b/tests/boundary/mcp/universalSliceMcp.test.ts index ddc60a87222..7d92c2fa9f2 100644 --- a/tests/boundary/mcp/universalSliceMcp.test.ts +++ b/tests/boundary/mcp/universalSliceMcp.test.ts @@ -325,19 +325,22 @@ sliceTest( }, }); expect(mixed.isError, JSON.stringify(mixed.content)).not.toBe(true); - expect( - goBinarySchema.parse( - toolContract("inspect_go_binary").outputSchema.parse( - mixed.structuredContent, - ).normalized_result, - ), - ).toMatchObject({ + const report = goBinarySchema.parse( + toolContract("inspect_go_binary").outputSchema.parse( + mixed.structuredContent, + ).normalized_result, + ); + expect(report).toMatchObject({ selected_slice: { offset: preferred.slices[1]?.offset, cpu_subtype: 0, }, build_info: { go_version: "go1.26.1" }, }); + // The arm64e slice is not read, and the result says so. + expect(report.limitations).toContain( + `Only the preferred arm64 slice (arm64 (subtype 0x0) at file offset ${String(preferred.slices[1]?.offset)}) was read; this universal Mach-O also holds arm64 (subtype 0x80000002) at file offset ${String(preferred.slices[0]?.offset)}, which no architecture value selects.`, + ); // Slices that share a base subtype leave no single slice to choose. const duplicate = await write( From 77b203d6eb18dc8cdaee1a9ced8160ecdc9105b4 Mon Sep 17 00:00:00 2001 From: chordpli Date: Tue, 10 Nov 2026 17:38:42 +0900 Subject: [PATCH 4/5] docs(native): name the canonical x86 subtype preference --- docs/guides/native/go-binaries.md | 6 +++--- docs/guides/native/native-initializers.md | 5 +++-- docs/rust-binaries.md | 6 +++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/guides/native/go-binaries.md b/docs/guides/native/go-binaries.md index 90efd993edd..8058ae9b240 100644 --- a/docs/guides/native/go-binaries.md +++ b/docs/guides/native/go-binaries.md @@ -168,9 +168,9 @@ 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, the request fails on `architecture` and lists the slices present. When several slices share the CPU family, REA reads the one `inspect_macho` -would select: `arm64` before `arm64e` before `arm64v8`, `x86_64` before other -x86_64 subtypes such as `x86_64h`, and FAT table order among subtypes with no -listed preference. `limitations` names the same-family slices it did not read. +would select: `arm64` before `arm64e` before `arm64v8`, the canonical `i386` +and `x86_64` subtypes before variants such as `x86_64h`, and FAT table order +among subtypes with no listed preference. `limitations` names the same-family slices it did not read. Slices that share a base subtype still fail on `architecture`. `architecture` on any other image is rejected rather than ignored. diff --git a/docs/guides/native/native-initializers.md b/docs/guides/native/native-initializers.md index 17577697bc2..8d4f977b6e0 100644 --- a/docs/guides/native/native-initializers.md +++ b/docs/guides/native/native-initializers.md @@ -23,8 +23,9 @@ requires an absolute filesystem path: A universal Mach-O requires `architecture` to select one slice. When several slices share the CPU family, such as the `arm64` and `arm64e` slices of stock macOS tools, REA reads the one `inspect_macho` would select (`arm64` before -`arm64e` before `arm64v8`, `x86_64` before `x86_64h`, and FAT table order among -subtypes with no listed preference) and reports it in `slice`. `limitations` +`arm64e` before `arm64v8`, the canonical `i386` and `x86_64` subtypes before +variants such as `x86_64h`, and FAT table order among subtypes with no listed +preference) and reports it in `slice`. `limitations` names the same-family slices it did not read. Slices that share a base subtype fail on `architecture`. ELF files and thin Mach-O files do not take it. diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md index 07fd71b474d..df0c31236a7 100644 --- a/docs/rust-binaries.md +++ b/docs/rust-binaries.md @@ -170,9 +170,9 @@ type/subtype and the FAT table digest, and reports the section and rustc-path of file offset inside that slice. Without `architecture`, or when the file has no such slice, the request fails on `architecture` and lists the slices present. When several slices share the CPU family, REA reads the one `inspect_macho` -would select: `arm64` before `arm64e` before `arm64v8`, `x86_64` before other -x86_64 subtypes such as `x86_64h`, and FAT table order among subtypes with no -listed preference. `limitations` names the same-family slices it did not read. +would select: `arm64` before `arm64e` before `arm64v8`, the canonical `i386` +and `x86_64` subtypes before variants such as `x86_64h`, and FAT table order +among subtypes with no listed preference. `limitations` names the same-family slices it did not read. Slices that share a base subtype still fail on `architecture`. `architecture` on any other image is rejected rather than ignored. From ad4d33907155c31754da85ebfcf47da38f58ea57 Mon Sep 17 00:00:00 2001 From: chordpli Date: Tue, 10 Nov 2026 18:04:22 +0900 Subject: [PATCH 5/5] docs(native): refuse only a duplicated preferred slice subtype Qualify the remaining refusal as a duplicated winning subtype, state that unlisted subtypes follow the listed ones, and say the limitation names the selected slice rather than a preferred one. --- docs/guides/native/go-binaries.md | 14 ++++---- docs/guides/native/native-initializers.md | 14 ++++---- docs/rust-binaries.md | 14 ++++---- src/domain/apple/machoTargetSelection.ts | 2 +- src/native/MachoArchitectureSelection.test.ts | 32 +++++++++++++++---- src/native/MachoArchitectureSelection.ts | 6 ++-- src/native/metadata/MachoSliceSelection.ts | 2 +- .../mcp/nativeInitializersMcp.test.ts | 2 +- tests/boundary/mcp/universalSliceMcp.test.ts | 4 +-- 9 files changed, 57 insertions(+), 33 deletions(-) diff --git a/docs/guides/native/go-binaries.md b/docs/guides/native/go-binaries.md index 8058ae9b240..91e43768ef0 100644 --- a/docs/guides/native/go-binaries.md +++ b/docs/guides/native/go-binaries.md @@ -167,12 +167,14 @@ 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, the request fails on `architecture` and lists the slices present. -When several slices share the CPU family, REA reads the one `inspect_macho` -would select: `arm64` before `arm64e` before `arm64v8`, the canonical `i386` -and `x86_64` subtypes before variants such as `x86_64h`, and FAT table order -among subtypes with no listed preference. `limitations` names the same-family slices it did not read. -Slices that share a base subtype still fail on `architecture`. -`architecture` on any other image is rejected rather than ignored. +When several slices share the CPU family, REA reads the preferred subtype: +`arm64` before `arm64e` before `arm64v8`, and the canonical `i386` and `x86_64` +subtypes before variants such as `x86_64h`. Subtypes with no listed preference +come after the listed ones, in FAT table order. `limitations` names the +same-family slices it did not read. The request still fails on `architecture` +when the preferred subtype itself appears more than once (capability bits +aside), such as two `arm64` subtype 0 slices. `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/guides/native/native-initializers.md b/docs/guides/native/native-initializers.md index 8d4f977b6e0..7cac8faca2d 100644 --- a/docs/guides/native/native-initializers.md +++ b/docs/guides/native/native-initializers.md @@ -22,13 +22,13 @@ requires an absolute filesystem path: A universal Mach-O requires `architecture` to select one slice. When several slices share the CPU family, such as the `arm64` and `arm64e` slices of stock -macOS tools, REA reads the one `inspect_macho` would select (`arm64` before -`arm64e` before `arm64v8`, the canonical `i386` and `x86_64` subtypes before -variants such as `x86_64h`, and FAT table order among subtypes with no listed -preference) and reports it in `slice`. `limitations` -names the same-family slices it did not read. Slices that share a base subtype -fail on `architecture`. ELF files and -thin Mach-O files do not take it. +macOS tools, REA reads the preferred subtype and reports it in `slice`: `arm64` +before `arm64e` before `arm64v8`, and the canonical `i386` and `x86_64` subtypes +before variants such as `x86_64h`. Subtypes with no listed preference come after +the listed ones, in FAT table order. `limitations` names the same-family slices +it did not read. The request fails on `architecture` when the preferred subtype +itself appears more than once (capability bits aside), such as two `arm64` +subtype 0 slices. ELF files and thin Mach-O files do not take it. ## Sources diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md index df0c31236a7..c26d53ffe68 100644 --- a/docs/rust-binaries.md +++ b/docs/rust-binaries.md @@ -169,12 +169,14 @@ 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, the request fails on `architecture` and lists the slices present. -When several slices share the CPU family, REA reads the one `inspect_macho` -would select: `arm64` before `arm64e` before `arm64v8`, the canonical `i386` -and `x86_64` subtypes before variants such as `x86_64h`, and FAT table order -among subtypes with no listed preference. `limitations` names the same-family slices it did not read. -Slices that share a base subtype still fail on `architecture`. -`architecture` on any other image is rejected rather than ignored. +When several slices share the CPU family, REA reads the preferred subtype: +`arm64` before `arm64e` before `arm64v8`, and the canonical `i386` and `x86_64` +subtypes before variants such as `x86_64h`. Subtypes with no listed preference +come after the listed ones, in FAT table order. `limitations` names the +same-family slices it did not read. The request still fails on `architecture` +when the preferred subtype itself appears more than once (capability bits +aside), such as two `arm64` subtype 0 slices. `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/domain/apple/machoTargetSelection.ts b/src/domain/apple/machoTargetSelection.ts index e53856e159e..6372b022e84 100644 --- a/src/domain/apple/machoTargetSelection.ts +++ b/src/domain/apple/machoTargetSelection.ts @@ -56,7 +56,7 @@ export const MACHO_CPU_TYPES = { 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. When several slices share the family, the preferred subtype is read (arm64 before arm64e).", + "CPU family of the slice to read from a universal (FAT) Mach-O. Required for universal files and rejected for every other image. When several slices share the family, the preferred subtype is read (arm64 before arm64e); a duplicated preferred subtype is refused.", ) satisfies z.ZodType; export type MachoSliceArchitecture = z.infer< typeof machoSliceArchitectureSchema diff --git a/src/native/MachoArchitectureSelection.test.ts b/src/native/MachoArchitectureSelection.test.ts index a368c9d8114..d60ad9531bb 100644 --- a/src/native/MachoArchitectureSelection.test.ts +++ b/src/native/MachoArchitectureSelection.test.ts @@ -13,11 +13,14 @@ const slice = (index: number, cpuType: number, cpuSubtype: number) => ({ describe("selectPreferredMachoSlice", () => { it("prefers listed subtypes over FAT table order", () => { - const x86 = [slice(0, CPU_X86_64, 8), slice(1, CPU_X86_64, 3)]; - expect(selectPreferredMachoSlice(x86, "x86_64")).toEqual({ - status: "selected", - slice: x86[1], - }); + for (const x86 of [ + [slice(0, CPU_X86_64, 8), slice(1, CPU_X86_64, 3)], + [slice(0, CPU_X86_64, 3), slice(1, CPU_X86_64, 8)], + ]) + expect(selectPreferredMachoSlice(x86, "x86_64")).toEqual({ + status: "selected", + slice: x86.find(({ cpuSubtype }) => cpuSubtype === 3), + }); const arm64 = [slice(0, CPU_ARM64, 0x80000002), slice(1, CPU_ARM64, 0)]; expect(selectPreferredMachoSlice(arm64, "arm64")).toEqual({ status: "selected", @@ -34,7 +37,24 @@ describe("selectPreferredMachoSlice", () => { }); }); - it("refuses slices that share a base subtype and reports absent families", () => { + it("refuses only a duplicated winning subtype and reports absent families", () => { + // Duplicate arm64e slices do not matter once the arm64 slice wins. + const layered = [ + slice(0, CPU_ARM64, 0x80000002), + slice(1, CPU_ARM64, 0), + slice(2, CPU_ARM64, 0x80000002), + ]; + expect(selectPreferredMachoSlice(layered, "arm64")).toEqual({ + status: "selected", + slice: layered[1], + }); + // Capability bits do not distinguish a duplicate winner. + expect( + selectPreferredMachoSlice( + [slice(0, CPU_ARM64, 0x80000002), slice(1, CPU_ARM64, 0x81000002)], + "arm64", + ), + ).toEqual({ status: "ambiguous" }); const duplicate = [slice(0, CPU_ARM64, 0), slice(1, CPU_ARM64, 0)]; expect(selectPreferredMachoSlice(duplicate, "arm64")).toEqual({ status: "ambiguous", diff --git a/src/native/MachoArchitectureSelection.ts b/src/native/MachoArchitectureSelection.ts index a916fc12600..c2ba092effc 100644 --- a/src/native/MachoArchitectureSelection.ts +++ b/src/native/MachoArchitectureSelection.ts @@ -53,9 +53,9 @@ export type PreferredMachoSlice = | { readonly status: "ambiguous" }; /** - * Choose the one FAT slice an architecture selects. Lower subtype ranks win; - * equal ranks keep FAT table order unless the slices share a base subtype, - * which leaves no single slice to choose. + * Choose the one FAT slice an architecture selects. Lower subtype ranks win and + * equal ranks keep FAT table order. The choice is ambiguous only when the + * winning base subtype (capability bits cleared) appears more than once. */ export const selectPreferredMachoSlice = < Slice extends { readonly cpuType: number; readonly cpuSubtype: number }, diff --git a/src/native/metadata/MachoSliceSelection.ts b/src/native/metadata/MachoSliceSelection.ts index 462eb101036..8cbb89872d2 100644 --- a/src/native/metadata/MachoSliceSelection.ts +++ b/src/native/metadata/MachoSliceSelection.ts @@ -105,7 +105,7 @@ export const selectNativeImage = ( unread.length === 0 ? [] : [ - `Only the preferred ${architecture} slice (${cpuName(slice.cpuType, slice.cpuSubtype)} at file offset ${String(slice.offset)}) was read; this universal Mach-O also holds ${unread.join(", ")}, which no architecture value selects.`, + `Only the selected ${architecture} slice (${cpuName(slice.cpuType, slice.cpuSubtype)} at file offset ${String(slice.offset)}) was read; this universal Mach-O also holds ${unread.join(", ")}, which no architecture value selects.`, ], }); }; diff --git a/tests/boundary/mcp/nativeInitializersMcp.test.ts b/tests/boundary/mcp/nativeInitializersMcp.test.ts index 6dead44d87d..82e34a4be68 100644 --- a/tests/boundary/mcp/nativeInitializersMcp.test.ts +++ b/tests/boundary/mcp/nativeInitializersMcp.test.ts @@ -129,7 +129,7 @@ mcpTest( expect(evidence.normalized_result.entries.length).toBeGreaterThan(0); // The arm64e slice is not read, and the result says so. expect(evidence.normalized_result.limitations).toContain( - `Only the preferred arm64 slice (arm64 (subtype 0x0) at file offset ${String(universal.slices[1]?.offset)}) was read; this universal Mach-O also holds arm64 (subtype 0x80000002) at file offset ${String(universal.slices[2]?.offset)}, which no architecture value selects.`, + `Only the selected arm64 slice (arm64 (subtype 0x0) at file offset ${String(universal.slices[1]?.offset)}) was read; this universal Mach-O also holds arm64 (subtype 0x80000002) at file offset ${String(universal.slices[2]?.offset)}, which no architecture value selects.`, ); } finally { await session.close(); diff --git a/tests/boundary/mcp/universalSliceMcp.test.ts b/tests/boundary/mcp/universalSliceMcp.test.ts index 7d92c2fa9f2..fb37bb0dd5d 100644 --- a/tests/boundary/mcp/universalSliceMcp.test.ts +++ b/tests/boundary/mcp/universalSliceMcp.test.ts @@ -339,10 +339,10 @@ sliceTest( }); // The arm64e slice is not read, and the result says so. expect(report.limitations).toContain( - `Only the preferred arm64 slice (arm64 (subtype 0x0) at file offset ${String(preferred.slices[1]?.offset)}) was read; this universal Mach-O also holds arm64 (subtype 0x80000002) at file offset ${String(preferred.slices[0]?.offset)}, which no architecture value selects.`, + `Only the selected arm64 slice (arm64 (subtype 0x0) at file offset ${String(preferred.slices[1]?.offset)}) was read; this universal Mach-O also holds arm64 (subtype 0x80000002) at file offset ${String(preferred.slices[0]?.offset)}, which no architecture value selects.`, ); - // Slices that share a base subtype leave no single slice to choose. + // A winning subtype that appears twice leaves no single slice to choose. const duplicate = await write( "duplicate-arm64", universalMacho([