mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(native): classify dyld refusals before export fallback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user