From c101193eb33f6cc68ac57b95c618cf8a1c86fd29 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:33:40 +0800 Subject: [PATCH] fix(native): classify dyld refusals before export fallback --- docs/native-investigation.md | 10 +++ src/native/NativeMachoCommandDiagnostics.ts | 61 ++++++++----- src/native/NativeMachoInspection.ts | 49 ++++++++--- .../cli/nativeMachoMissingUuidCli.test.ts | 74 ++++++++++++++++ .../nativeMachoUuidlessInspection.test.ts | 87 ++++++++++++++++++- 5 files changed, 244 insertions(+), 37 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index eb64d16a35..364d213166 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -511,6 +511,16 @@ to the linked Mach-O. This classification requires both the recognized native refusal and an independently parsed dSYM Mach header; other tool failures keep their failure outcome. +Observed `dyld_info` diagnostics include `unknown mh.filetype 4` and +`unknown filetype 4` for `MH_CORE`, and the corresponding `3` spellings for +`MH_FVMLIB`. These refusals report `unsupported_target` when the independently +parsed Mach header confirms the reported file type. The error retains the exact +native diagnostic and recommends a linked Mach-O executable or relocatable +object. This classifies the selected target's refusal; it does not establish +support for core-dump analysis, historical FVMLIB layouts, or unobserved native +diagnostic spellings. Unknown or contradictory header values and signalled +commands remain failures. + `inspect_macho.selected_architecture` identifies the exact selected lipo slice and its `container_file_offset` in the input file. A thin Mach-O has a known offset of zero; an unknown universal-slice offset stays null. The Evidence diff --git a/src/native/NativeMachoCommandDiagnostics.ts b/src/native/NativeMachoCommandDiagnostics.ts index 6f35520328..b394b9f83d 100644 --- a/src/native/NativeMachoCommandDiagnostics.ts +++ b/src/native/NativeMachoCommandDiagnostics.ts @@ -5,40 +5,55 @@ import { withoutEchoedPathHeader } from "./parsers/echoedPath.js"; const MISSING_UUID_DIAGNOSTIC = "missing LC_UUID load command"; +const dyldFailureDiagnostics = ( + capture: NativeCommandCapture, + outputs: readonly string[], +): readonly string[] => + capture.tool !== "dyld_info" || + capture.exitCode === null || + capture.exitCode === 0 || + capture.signal !== null + ? [] + : outputs.flatMap((output) => + withoutEchoedPathHeader(output, capture.arguments.at(-1)) + .split(/\r?\n/u) + .map((line) => line.trim().replace(/^(?:dyld_info:\s*)/u, "")), + ); + /** Recognize only dyld_info's target-specific missing-UUID refusal. */ export const isMissingUuidDyldFailure = ( capture: NativeCommandCapture, ): boolean => - capture.tool === "dyld_info" && - capture.exitCode !== null && - capture.exitCode !== 0 && - capture.signal === null && - withoutEchoedPathHeader(capture.stdout, capture.arguments.at(-1)) - .split(/\r?\n/u) - .some( - (line) => - line.trim().replace(/^(?:dyld_info:\s*)/u, "") === - MISSING_UUID_DIAGNOSTIC, - ); + dyldFailureDiagnostics(capture, [capture.stdout]).includes( + MISSING_UUID_DIAGNOSTIC, + ); /** The narrowly observed dyld refusal for an unbacked dSYM text section. */ export const isUnbackedTextDyldFailure = ( capture: NativeCommandCapture, ): boolean => - capture.tool === "dyld_info" && - capture.exitCode !== null && - capture.exitCode !== 0 && - capture.signal === null && - [capture.stdout, capture.stderr].some((output) => - withoutEchoedPathHeader(output, capture.arguments.at(-1)) - .split(/\r?\n/u) - .some( - (line) => - line.trim().replace(/^(?:dyld_info:\s*)/u, "") === - "section '__TEXT/__text' has offset=0 but is not a zero-fill section type", - ), + dyldFailureDiagnostics(capture, [capture.stdout, capture.stderr]).includes( + "section '__TEXT/__text' has offset=0 but is not a zero-fill section type", ); +/** Reported CORE/FVMLIB refusals still require an independently matching header. */ +export const parseDyldFileTypeRefusal = ( + capture: NativeCommandCapture, +): { + readonly fileType: "3" | "4"; + readonly diagnostic: string; +} | null => { + for (const line of dyldFailureDiagnostics(capture, [ + capture.stdout, + capture.stderr, + ])) { + const fileType = /^unknown (?:mh\.)?filetype ([34])$/u.exec(line)?.[1]; + if (fileType === "3" || fileType === "4") + return { fileType, diagnostic: line }; + } + return null; +}; + /** Complete captures come from the settled native command owner. */ export const nativeMachoCapturedOutput = ( capture: NativeCommandCapture, diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 5ca5dea008..5ebc74debc 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -28,6 +28,7 @@ import { isUnbackedTextDyldFailure, nativeMachoCapturedOutput, missingUuidLimitation, + parseDyldFileTypeRefusal, unusableNativeCommandOutput, } from "./NativeMachoCommandDiagnostics.js"; @@ -118,7 +119,8 @@ export const inspectNativeMacho = async ( tool === "dyld_info" ? (capture) => isMissingUuidDyldFailure(capture) || - isUnbackedTextDyldFailure(capture) + isUnbackedTextDyldFailure(capture) || + parseDyldFileTypeRefusal(capture) !== null : undefined, ); // vtool knows no variant subtypes, so no spelling selects an arm64e.x1 slice. @@ -139,7 +141,8 @@ export const inspectNativeMacho = async ( if ( (captured.value.exitCode !== 0 || captured.value.signal !== null) && !isMissingUuidDyldFailure(captured.value) && - !isUnbackedTextDyldFailure(captured.value) + !isUnbackedTextDyldFailure(captured.value) && + parseDyldFileTypeRefusal(captured.value) === null ) return err(unusableNativeCommandOutput(captured.value)); captures.push(captured.value); @@ -157,17 +160,6 @@ export const inspectNativeMacho = async ( const dyldExports = captures.filter(({ tool }) => tool === "dyld_info")[1]; if (nmIndex < 0 || dyldExports === undefined) throw new TypeError("Missing nm or dyld_info export capture"); - // A stripped symbol table (strip -T) can omit names the export trie keeps. - // Only then does llvm-nm's dyld-info pass add names, and its exact names - // disambiguate dyld_info rows, so rerun it rather than lose them. - if ( - !isMissingUuidDyldFailure(dyldExports) && - !namesEveryExport(captures[nmIndex]!.stdout, dyldExports) - ) { - const withDyldInfo = await runSliceCommand(DYLD_INFO_EXPORTS); - if (!withDyldInfo.ok) return err(withDyldInfo.error); - captures[nmIndex] = withDyldInfo.value; - } const otool = captures.find(({ tool }) => tool === "otool"); if (otool === undefined) throw new TypeError("Missing otool capture"); let load: ReturnType; @@ -202,12 +194,43 @@ export const inspectNativeMacho = async ( ), ); } + for (const fileTypeFailure of captures) { + const refusal = parseDyldFileTypeRefusal(fileTypeFailure); + if (refusal === null) continue; + const { fileType, diagnostic } = refusal; + const typeName = fileType === "4" ? "CORE" : "FVMLIB"; + if (![fileType, typeName, `MH_${typeName}`].includes(load.fileType ?? "")) + return err(unusableNativeCommandOutput(fileTypeFailure)); + return err( + new AnalysisUnsupportedTargetError( + "inspect_macho", + context.target.path, + `dyld_info refused the selected Mach-O file type MH_${typeName} (${fileType}): ${diagnostic}`, + { + capturedOutput: nativeMachoCapturedOutput(fileTypeFailure), + remediationAction: + "Select a linked Mach-O executable or relocatable object for native inspection; dyld_info refused the selected file type.", + }, + ), + ); + } const uuidlessDyldFailure = captures.find(isMissingUuidDyldFailure); if ( uuidlessDyldFailure !== undefined && load.commands.some(({ kind }) => kind === "LC_UUID") ) return err(conflictingUuidCommandEvidence(uuidlessDyldFailure)); + // A stripped symbol table (strip -T) can omit names the export trie keeps. + // Only then does llvm-nm's dyld-info pass add names, and its exact names + // disambiguate dyld_info rows. Refusals above are not export rows. + if ( + !isMissingUuidDyldFailure(dyldExports) && + !namesEveryExport(captures[nmIndex]!.stdout, dyldExports) + ) { + const withDyldInfo = await runSliceCommand(DYLD_INFO_EXPORTS); + if (!withDyldInfo.ok) return err(withDyldInfo.error); + captures[nmIndex] = withDyldInfo.value; + } const selectedArchitectureName = selectedArchitecture ?? (architectures.length === 1 ? (architectures[0]?.name ?? null) : null); diff --git a/tests/boundary/cli/nativeMachoMissingUuidCli.test.ts b/tests/boundary/cli/nativeMachoMissingUuidCli.test.ts index 71285073c5..a054c0fefe 100644 --- a/tests/boundary/cli/nativeMachoMissingUuidCli.test.ts +++ b/tests/boundary/cli/nativeMachoMissingUuidCli.test.ts @@ -3,12 +3,41 @@ import { writeFile } from "node:fs/promises"; import { describe, expect } from "vitest"; +import { + machoImage, + segmentCommand, +} from "../../../src/artifacts/apple/MachoImage.fixture.js"; import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +/** Match the readable synthetic CORE/header fixture reported in #5708. */ +const refusedFileTypeImage = (fileType: 3 | 4): Buffer => { + const segment = Buffer.from( + segmentCommand({ + name: "__TEXT", + vmaddr: 0x100000000n, + vmsize: 0x4000n, + }), + ); + segment.writeBigUInt64LE(4096n, 48); + segment.writeUInt32LE(7, 56); + segment.writeUInt32LE(5, 60); + const uuid = Buffer.alloc(24); + uuid.writeUInt32LE(0x1b, 0); + uuid.writeUInt32LE(uuid.length, 4); + uuid.set( + Array.from({ length: 16 }, (_, index) => index), + 8, + ); + const image = Buffer.alloc(4096); + image.set(machoImage({ fileType, commands: [segment, uuid] })); + return image; +}; + describe.skipIf(process.platform !== "darwin")( "native Mach-O CLI without LC_UUID", () => { @@ -100,6 +129,51 @@ describe.skipIf(process.platform !== "darwin")( expect(report.exports).toEqual(missingUuid.exports); expect(report.load_commands).toEqual(missingUuid.load_commands); expect((await call("close_binary", {})).isError).not.toBe(true); + + // These are synthetic readable headers, not an OS core dump or a + // conforming historical FVMLIB (the latter omits LC_IDFVMLIB). + for (const [name, fileType] of [ + ["core", 4], + ["fvmlib", 3], + ] as const) { + const path = join(directory, name); + await writeFile(path, refusedFileTypeImage(fileType)); + const diagnosticPattern = new RegExp( + `unknown (?:mh\\.)?filetype ${fileType}(?:\\r?\\n|$)`, + "u", + ); + const expectedRefusal = { + code: "unsupported_target", + category: "unsupported_target", + remediation: { action: expect.stringContaining("linked Mach-O") }, + details: { + path, + reason: expect.stringMatching(diagnosticPattern), + captured_output: { + stdout: expect.stringMatching(diagnosticPattern), + exit_code: 1, + signal: null, + truncated: false, + }, + }, + }; + const refused = await cli.run({ + arguments: ["inspect-macho", path, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(refused.exitCode, refused.stdout + refused.stderr).toBe(1); + expect(refused.json, JSON.stringify(refused.json)).toMatchObject( + expectedRefusal, + ); + + expect((await call("open_binary", { path })).isError).not.toBe(true); + const inspected = await call("inspect_macho", {}); + expect(inspected.isError).toBe(true); + expect(parseMcpToolError(inspected)).toMatchObject({ + error: expectedRefusal, + }); + expect((await call("close_binary", {})).isError).not.toBe(true); + } }, ); }, diff --git a/tests/boundary/providers/native/nativeMachoUuidlessInspection.test.ts b/tests/boundary/providers/native/nativeMachoUuidlessInspection.test.ts index fb1b4490a3..5fa0741407 100644 --- a/tests/boundary/providers/native/nativeMachoUuidlessInspection.test.ts +++ b/tests/boundary/providers/native/nativeMachoUuidlessInspection.test.ts @@ -42,11 +42,16 @@ const otoolOutputWithoutUuid = async () => { class UuidlessDyldRunner implements NativeCommandRunner { readonly fixture: NativeFixtureRunner; + readonly commands: { + readonly tool: string; + readonly args: readonly string[]; + }[] = []; constructor( otool: string, readonly diagnostic = missingUuidDiagnostic, readonly stderr = "", + readonly signal: string | null = null, ) { this.fixture = new NativeFixtureRunner({ "otool:-h": otool, @@ -59,6 +64,7 @@ class UuidlessDyldRunner implements NativeCommandRunner { arguments_: readonly string[], options: NativeCommandOptions, ) { + this.commands.push({ tool, args: arguments_ }); if (tool !== "dyld_info") return this.fixture.run(tool, arguments_); const capture: NativeCommandCapture = { tool, @@ -74,7 +80,7 @@ class UuidlessDyldRunner implements NativeCommandRunner { ), stderrBytes: Buffer.byteLength(this.stderr), exitCode: 1, - signal: null, + signal: this.signal, }; if (options.acceptNonZero) return ok(capture); return err( @@ -91,6 +97,85 @@ class UuidlessDyldRunner implements NativeCommandRunner { } } +it.each([ + { fileType: "4", diagnostic: "unknown mh.filetype 4" }, + { fileType: "CORE", diagnostic: "unknown mh.filetype 4" }, + { fileType: "3", diagnostic: "unknown mh.filetype 3" }, + { fileType: "4", diagnostic: "unknown filetype 4" }, + { fileType: "3", diagnostic: "unknown filetype 3" }, + { + fileType: "10", + diagnostic: + "section '__TEXT/__text' has offset=0 but is not a zero-fill section type", + }, +])( + "classifies $diagnostic for Mach-O type $fileType before nm fallback", + async ({ fileType, diagnostic }) => { + const otool = [ + "Mach header", + "magic cputype cpusubtype caps filetype ncmds sizeofcmds flags", + `0xfeedfacf 16777228 0 0x00 ${fileType} 0 0 0x0`, + ].join("\n"); + const runner = new UuidlessDyldRunner(otool, diagnostic); + const path = "/fixture/refused-type"; + const result = await new NativeMacOSProvider({}, runner, "darwin") + .createClient(nativeMachoTarget(path)) + .execute("inspect_macho", {}); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisUnsupportedTargetError", + path, + reason: expect.stringContaining( + fileType === "10" ? "dSYM" : diagnostic, + ), + remediationAction: expect.stringContaining("linked Mach-O"), + capturedOutput: { + stdout: dyldDiagnosticOutput(path, diagnostic), + exit_code: 1, + signal: null, + truncated: false, + }, + }, + }); + expect(runner.commands.filter(({ tool }) => tool === "nm")).toEqual([ + expect.objectContaining({ + args: expect.arrayContaining(["--no-dyldinfo"]), + }), + ]); + }, +); + +it.each([ + { fileType: "2", signal: null, diagnostic: "unknown mh.filetype 4" }, + { fileType: "UNKNOWN", signal: null, diagnostic: "unknown mh.filetype 4" }, + { fileType: "4", signal: "SIGTERM", diagnostic: "unknown mh.filetype 4" }, + { fileType: "2", signal: null, diagnostic: "unknown filetype 4" }, + { fileType: "4", signal: "SIGTERM", diagnostic: "unknown filetype 4" }, +])( + "keeps $diagnostic as a failure for header $fileType and signal $signal", + async ({ fileType, signal, diagnostic }) => { + const otool = [ + "Mach header", + "magic cputype cpusubtype caps filetype ncmds sizeofcmds flags", + `0xfeedfacf 16777228 0 0x00 ${fileType} 0 0 0x0`, + ].join("\n"); + const runner = new UuidlessDyldRunner(otool, diagnostic, "", signal); + const result = await new NativeMacOSProvider({}, runner, "darwin") + .createClient(nativeMachoTarget("/fixture/conflicting-type")) + .execute("inspect_macho", {}); + + expect(result).toMatchObject({ + ok: false, + error: { + _tag: signal === null ? "AnalysisOutputError" : "ProviderAdapterError", + capturedOutput: { exit_code: 1, signal }, + }, + }); + }, +); + it("returns readable Mach-O Evidence when dyld_info identifies a missing UUID", async () => { const withoutUuid = await otoolOutputWithoutUuid(); const runner = new UuidlessDyldRunner(withoutUuid);