From 3f5b7033799f9fe993ad5b81fb51c8f822173817 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:44:41 -0700 Subject: [PATCH] fix(native): derive Mach-O word size and byte order from the selected slice (#3163) * fix(native): derive Mach-O word size and byte order from the selected slice inspect_macho read endian and word_size from `file -b` for the whole file. macOS `file` never prints a Mach-O byte order, so endian was null for every target. For a universal binary, word_size matched "64-bit" anywhere in the summary instead of describing the selected slice, and it was null for a thin 32-bit slice. Both now come from the selected slice's otool header: the magic gives the word size and the CPU family gives the byte order (x86 and ARM little, PowerPC big, other families unknown). The file capture is no longer needed by inspect_macho, so it is no longer run there. Co-Authored-By: Claude Opus 5.5 * test(native): select a 32-bit slice beside a 64-bit slice through inspect_macho Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/native/NativeMachoInspection.test.ts | 7 ++-- src/native/NativeMachoInspection.ts | 19 ++-------- src/native/parsers/otool.ts | 22 +++++++++++ .../native/nativeInventory.golden.test.ts | 2 +- .../native/nativeMachoHeader.golden.test.ts | 18 +++++++++ .../native/nativeSelectedArchitecture.test.ts | 37 ++++++++++++++++++- tests/fixtures/nativeCommands.ts | 2 +- 7 files changed, 84 insertions(+), 23 deletions(-) diff --git a/src/native/NativeMachoInspection.test.ts b/src/native/NativeMachoInspection.test.ts index 640e540be9..9e9ef8dd33 100644 --- a/src/native/NativeMachoInspection.test.ts +++ b/src/native/NativeMachoInspection.test.ts @@ -53,7 +53,6 @@ const inspect = async (lipo: string, architecture: "x86" | "arm64") => { run: (tool, args) => { commands.push({ tool, args }); if (tool === "lipo") return Promise.resolve(ok(capture(tool, lipo))); - if (tool === "file") return Promise.resolve(ok(capture(tool, "Mach-O"))); return Promise.resolve(err(new AnalysisCancelledError("inspect_native"))); }, invocation: () => { @@ -72,7 +71,7 @@ describe("Mach-O tool architecture selection", () => { _tag: "AnalysisOutputError", reason: "lipo did not list i386; refusing to read another slice", }); - expect(commands.map(({ tool }) => tool)).toEqual(["file", "lipo"]); + expect(commands.map(({ tool }) => tool)).toEqual(["lipo"]); }); it("passes the arm64e slice lipo listed for an arm64 target", async () => { @@ -264,7 +263,7 @@ describe("Mach-O slice command scheduling", () => { let maxInFlight = 0; let launched = 0; const run = async (tool: string, args: readonly string[]) => { - if (tool === "file" || tool === "lipo") return fixtures.run(tool, args); + if (tool === "lipo") return fixtures.run(tool, args); const order = launched++; inFlight += 1; maxInFlight = Math.max(maxInFlight, inFlight); @@ -300,7 +299,7 @@ describe("Mach-O slice command scheduling", () => { if (!result.ok) throw result.error; expect(runner.maxInFlight()).toBe(SLICE_TOOLS.length + 1); expect(result.value.result).toMatchObject({ - provenance: ["file", "lipo", ...SLICE_TOOLS, "vtool"].map((tool) => ({ + provenance: ["lipo", ...SLICE_TOOLS, "vtool"].map((tool) => ({ tool, })), }); diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 8453f38a8d..5e84da5748 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -39,10 +39,7 @@ interface NativeMachoInspectionContext { ) => NativeCommandInvocation; } -const DISCOVERY_COMMANDS = [ - ["file", ["-b"]], - ["lipo", ["-detailed_info"]], -] as const; +const DISCOVERY_COMMANDS = [["lipo", ["-detailed_info"]]] as const; const REQUIRED_SLICE_COMMANDS = [ ["otool", ["-h", "-l"]], @@ -247,8 +244,8 @@ const normalizeMacho = ( return inspectMachoSchema.parse({ format: "mach-o", selected_architecture: selectedArchitecture, - endian: parseEndian(byTool("file").stdout), - word_size: parseWordSize(byTool("file").stdout), + endian: load.endian, + word_size: load.wordSize, file_type: load.fileType, flags: load.flags, uuid: uuid.value, @@ -308,16 +305,6 @@ const parseNmExports = (output: string) => source: "nm", })); -const parseEndian = (output: string): "little" | "big" | null => - /little-endian/iu.test(output) - ? "little" - : /big-endian/iu.test(output) - ? "big" - : null; - -const parseWordSize = (output: string): 32 | 64 | null => - /64-bit/iu.test(output) ? 64 : /32-bit/iu.test(output) ? 32 : null; - const covered = ( items: readonly Value[], exhaustive: boolean, diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index 8f1ed9dda9..10804b126c 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -134,6 +134,8 @@ export const parseOtoolLoadCommands = (raw: string) => { parseLoadCommand(block, state); validateLoadCommandFraming(header, state.commands); return { + wordSize: WORD_SIZES.get(header.magic ?? "") ?? null, + endian: header.cpuType === null ? null : cpuEndian(header.cpuType), fileType: header.fileType, flags: [...state.flags].sort(), uuid: state.uuid, @@ -145,10 +147,27 @@ export const parseOtoolLoadCommands = (raw: string) => { }; }; +/** otool prints the selected slice's magic in host order, symbolic with -v. */ +const WORD_SIZES: ReadonlyMap = new Map([ + ["0xfeedface", 32], + ["0xfeedfacf", 64], + ["MH_MAGIC", 32], + ["MH_MAGIC_64", 64], +]); + +/** Mach-O byte order by CPU family, ignoring the ABI bits; others stay unknown. */ +const cpuEndian = (cpuType: number): "little" | "big" | null => { + const family = cpuType & 0x00ffffff; + if (family === 7 || family === 12) return "little"; + return family === 18 ? "big" : null; +}; + const parseHeader = ( output: string, ): | { + magic: string | null; + cpuType: number | null; fileType: string | null; flags: string[]; commandCount: number; @@ -175,7 +194,10 @@ const parseHeader = ( : fileTypeIndex; const flagsEnd = flagsIndex >= 0 ? values.length - (columns.length - flagsIndex - 1) : 0; + const magicIndex = columns.indexOf("magic"); return { + magic: magicIndex < 0 ? null : (values[magicIndex] ?? null), + cpuType: headerValue(columns, values, "cputype"), commandCount, commandBytes, fileType: diff --git a/tests/boundary/providers/native/nativeInventory.golden.test.ts b/tests/boundary/providers/native/nativeInventory.golden.test.ts index 934d241375..ff86f60caf 100644 --- a/tests/boundary/providers/native/nativeInventory.golden.test.ts +++ b/tests/boundary/providers/native/nativeInventory.golden.test.ts @@ -142,7 +142,7 @@ it("rejects nonzero supplemental codesign output that is not an unsigned observa it("retains native inventory facts from captured Apple tool output", async () => { const outputs: Record = {}; - for (const tool of ["file", "lipo", "otool", "nm", "dwarfdump", "vtool"]) + for (const tool of ["lipo", "otool", "nm", "dwarfdump", "vtool"]) outputs[tool] = await fixture(`dyld-inventory/${tool}.txt`); outputs.otool = withOtoolMachHeader(outputs.otool ?? ""); outputs["dyld_info:-imports"] = await fixture("dyld-inventory/imports.txt"); diff --git a/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts b/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts index 8a1151df35..efd4d88734 100644 --- a/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts +++ b/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts @@ -40,6 +40,24 @@ it("retains the delayed dependency in the complete real framed otool output", as expect(parseOtoolLoadCommands(output).commands).toHaveLength(23); }); +it.each([ + ["0xfeedfacf 16777228", 64, "little"], + ["0xfeedfacf 16777223", 64, "little"], + ["0xfeedface 7", 32, "little"], + ["0xfeedface 18", 32, "big"], + ["0xfeedface 6", 32, null], + ["0xfeedfade 16777228", null, "little"], +] as const)( + "derives word size and byte order from the selected slice header %s", + async (header, wordSize, endian) => { + const output = (await nativeFixture("otool-dylib-use.txt")).replace( + "0xfeedfacf 16777228", + header, + ); + expect(parseOtoolLoadCommands(output)).toMatchObject({ wordSize, endian }); + }, +); + it("rejects a repeated options field even when the duplicate has no value", async () => { const output = (await nativeFixture("otool-dylib-use.txt")).replace( " options delay-init", diff --git a/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts b/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts index 182c336121..f1597bec6f 100644 --- a/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts +++ b/tests/boundary/providers/native/nativeSelectedArchitecture.test.ts @@ -27,7 +27,7 @@ const sliceSelectingRunner = ( calls.push({ tool, arguments_ }); const expected = tool === "dyld_info" ? dyldArchitecture : selected; if ( - !["file", "lipo"].includes(tool) && + tool !== "lipo" && !arguments_.some( (argument, index) => argument === expected || @@ -313,3 +313,38 @@ it.each([ }); }, ); + +it("reports the selected 32-bit slice's word size and byte order beside a 64-bit slice", async () => { + const i386Header = (await nativeFixture("otool-load.txt")).replace( + "0xfeedfacf 16777228", + "0xfeedface 7", + ); + const fixtures = new NativeFixtureRunner({ + lipo: [ + "architecture x86_64\n cputype 16777223\n cpusubtype 3\n offset 0\n size 8192\n align 2^14 (16384)", + "architecture i386\n cputype 7\n cpusubtype 3\n offset 16384\n size 8192\n align 2^14 (16384)", + ].join("\n"), + "otool:-h": i386Header, + dwarfdump: + "UUID: 01234567-89AB-CDEF-0123-456789ABCDEF (i386) /owned/universal\n", + }); + const base = nativeMachoTarget("/owned/universal"); + const result = await new NativeMacOSProvider( + {}, + sliceSelectingRunner(fixtures, "i386", "i386"), + "darwin", + ) + .createClient({ + ...base, + architecture: "x86", + availableArchitectures: ["x86_64", "x86"], + }) + .execute("inspect_macho", {}); + + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + selected_architecture: { name: "i386", container_file_offset: 16384 }, + word_size: 32, + endian: "little", + }); +}); diff --git a/tests/fixtures/nativeCommands.ts b/tests/fixtures/nativeCommands.ts index 34af395f93..5be6546591 100644 --- a/tests/fixtures/nativeCommands.ts +++ b/tests/fixtures/nativeCommands.ts @@ -42,7 +42,7 @@ const outputFor = async ( if (tool === "file") return arguments_.at(-1)?.endsWith(".plist") === true ? "XML 1.0 document text\n" - : "Mach-O 64-bit executable arm64, little-endian\n"; + : "Mach-O 64-bit executable arm64\n"; if (tool === "vtool") return "Load command 3 LC_BUILD_VERSION\n"; if (tool === "swift-demangle") return nativeFixture("demangle.txt"); if (tool === "plutil") return nativeFixture("plist.json");