fix(native): classify observed dSYM inspection refusal

This commit is contained in:
morluto committed 2026-10-24 04:05:23 +08:00
1 parent e1d9ebd3cf
commit f83d97fe92
4 files changed
+119 -30

No files matched your search

+6
View File
@@ -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
+34 -18
View File
@@ -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),
},
);
+48
View File
@@ -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,
});
},
);
+31 -12
View File
@@ -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.",
},
),
);