fix(native): classify dyld refusals before export fallback

This commit is contained in:
morluto
2026-10-24 16:33:40 +08:00
parent 172503355d
commit c101193eb3
5 changed files with 244 additions and 37 deletions
+10
View File
@@ -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
+38 -23
View File
@@ -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,
+36 -13
View File
@@ -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<typeof parseOtoolLoadCommands>;
@@ -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);
@@ -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);
}
},
);
},
@@ -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);