From f83d97fe928dc21730f4f01b5c31bb4d9fafc150 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:59:34 +0800 Subject: [PATCH] fix(native): classify observed dSYM inspection refusal --- docs/native-investigation.md | 6 +++ src/native/NativeMachoCommandDiagnostics.ts | 52 ++++++++++++++------- src/native/NativeMachoInspection.test.ts | 48 +++++++++++++++++++ src/native/NativeMachoInspection.ts | 43 ++++++++++++----- 4 files changed, 119 insertions(+), 30 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 5369fbd22d..f274ee352a 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -409,6 +409,12 @@ A conversion refusal includes plutil's diagnostic from either output stream. `details.captured_output` retains bounded stdout/stderr excerpts, observed byte totals and exit status. `truncated: true` means those excerpts are incomplete. +A dSYM companion that `dyld_info` refuses for an unbacked text section reports +`unsupported_target`, retaining the exact diagnostic and directing inspection +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. + `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 666ee78f36..6f35520328 100644 --- a/src/native/NativeMachoCommandDiagnostics.ts +++ b/src/native/NativeMachoCommandDiagnostics.ts @@ -1,3 +1,4 @@ +import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; import type { NativeCommandCapture } from "./CommandRunner.js"; import { withoutEchoedPathHeader } from "./parsers/echoedPath.js"; @@ -20,6 +21,37 @@ export const isMissingUuidDyldFailure = ( 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", + ), + ); + +/** Complete captures come from the settled native command owner. */ +export const nativeMachoCapturedOutput = ( + capture: NativeCommandCapture, +): AnalysisCapturedOutput => ({ + stdout: capture.stdout, + stderr: capture.stderr, + truncated: false, + stdout_bytes: capture.stdoutBytes, + stderr_bytes: capture.stderrBytes, + exit_code: capture.exitCode, + signal: capture.signal, +}); + /** Keep the exact target diagnostic beside the incomplete collection. */ export const missingUuidLimitation = (mode: "imports" | "exports"): string => mode === "imports" @@ -34,15 +66,7 @@ export const unusableNativeCommandOutput = ( "inspect_macho", `${capture.tool} did not produce a usable inspection result`, { - capturedOutput: { - stdout: capture.stdout, - stderr: capture.stderr, - truncated: false, - stdout_bytes: capture.stdoutBytes, - stderr_bytes: capture.stderrBytes, - exit_code: capture.exitCode, - signal: capture.signal, - }, + capturedOutput: nativeMachoCapturedOutput(capture), }, ); @@ -54,14 +78,6 @@ export const conflictingUuidCommandEvidence = ( "inspect_macho", "dyld_info reported a missing LC_UUID despite an observed LC_UUID load command", { - capturedOutput: { - stdout: command.stdout, - stderr: command.stderr, - truncated: false, - stdout_bytes: command.stdoutBytes, - stderr_bytes: command.stderrBytes, - exit_code: command.exitCode, - signal: command.signal, - }, + capturedOutput: nativeMachoCapturedOutput(command), }, ); diff --git a/src/native/NativeMachoInspection.test.ts b/src/native/NativeMachoInspection.test.ts index 32c9222a33..95b20e878c 100644 --- a/src/native/NativeMachoInspection.test.ts +++ b/src/native/NativeMachoInspection.test.ts @@ -403,3 +403,51 @@ describe("Mach-O slice command scheduling", () => { expect(result.error).toMatchObject({ reason: "nm failed" }); }); }); + +it.each(["10", "DSYM", "2"])( + "classifies the unbacked-text dyld refusal using observed file type %s", + async (fileType) => { + const diagnostic = + "section '__TEXT/__text' has offset=0 but is not a zero-fill section type\n"; + const runner = new NativeFixtureRunner({ + otool: [ + "Mach header", + "magic cputype cpusubtype caps filetype ncmds sizeofcmds flags", + `0xfeedfacf 16777228 0 0x00 ${fileType} 0 0 0x0`, + ].join("\n"), + }); + const result = await inspectNativeMacho({ + target: nativeMachoTarget("/fixture/debug-companion"), + run: async (tool, args, _signal, acceptNonZeroIf) => { + const captured = await runner.run(tool, args); + if (!captured.ok || tool !== "dyld_info") return captured; + const refused = { + ...captured.value, + stdout: diagnostic, + stdoutBytes: Buffer.byteLength(diagnostic), + exitCode: 1, + }; + if (acceptNonZeroIf?.(refused)) return ok(refused); + return err( + new AnalysisOutputError("inspect_macho", "unaccepted native failure"), + ); + }, + invocation: () => { + throw new Error( + "Refused target does not produce inspection provenance", + ); + }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error._tag).toBe( + fileType === "2" + ? "AnalysisOutputError" + : "AnalysisUnsupportedTargetError", + ); + expect(result.error.capturedOutput).toMatchObject({ + stdout: diagnostic, + exit_code: 1, + }); + }, +); diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index d76084d9fd..5ca5dea008 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -1,7 +1,10 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { EvidenceLocation } from "../domain/evidence.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; +import { + AnalysisOutputError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; import { inspectMachoSchema, type NativeCommandInvocation, @@ -22,6 +25,8 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { conflictingUuidCommandEvidence, isMissingUuidDyldFailure, + isUnbackedTextDyldFailure, + nativeMachoCapturedOutput, missingUuidLimitation, unusableNativeCommandOutput, } from "./NativeMachoCommandDiagnostics.js"; @@ -110,7 +115,11 @@ export const inspectNativeMacho = async ( context.target.path, ], context.signal, - tool === "dyld_info" ? isMissingUuidDyldFailure : undefined, + tool === "dyld_info" + ? (capture) => + isMissingUuidDyldFailure(capture) || + isUnbackedTextDyldFailure(capture) + : undefined, ); // vtool knows no variant subtypes, so no spelling selects an arm64e.x1 slice. const vtoolSelectsSlice = @@ -129,7 +138,8 @@ export const inspectNativeMacho = async ( if (!captured.ok) return err(captured.error); if ( (captured.value.exitCode !== 0 || captured.value.signal !== null) && - !isMissingUuidDyldFailure(captured.value) + !isMissingUuidDyldFailure(captured.value) && + !isUnbackedTextDyldFailure(captured.value) ) return err(unusableNativeCommandOutput(captured.value)); captures.push(captured.value); @@ -170,15 +180,24 @@ export const inspectNativeMacho = async ( "Malformed otool load-command output", { cause, - capturedOutput: { - stdout: otool.stdout, - stderr: otool.stderr, - truncated: false, - stdout_bytes: otool.stdoutBytes, - stderr_bytes: otool.stderrBytes, - exit_code: otool.exitCode, - signal: otool.signal, - }, + capturedOutput: nativeMachoCapturedOutput(otool), + }, + ), + ); + } + const unbackedTextFailure = captures.find(isUnbackedTextDyldFailure); + if (unbackedTextFailure !== undefined) { + if (!["10", "DSYM", "MH_DSYM"].includes(load.fileType ?? "")) + return err(unusableNativeCommandOutput(unbackedTextFailure)); + return err( + new AnalysisUnsupportedTargetError( + "inspect_macho", + context.target.path, + "The selected Mach-O is a dSYM companion whose unbacked text sections are unsupported by dyld_info inspection", + { + capturedOutput: nativeMachoCapturedOutput(unbackedTextFailure), + remediationAction: + "Select the matching linked Mach-O executable for native inspection; this operation does not inspect dSYM companions with unbacked text sections.", }, ), );