fix(objdump): preserve input issues and provider launch failures

This commit is contained in:
morluto committed 2026-10-24 04:06:57 +08:00
1 parent c86288db1b
commit 76381b29d4
4 files changed
+170 -19

No files matched your search

+7
View File
@@ -16,6 +16,13 @@ can run.
`inspect_with_objdump` and `rea inspect-with-objdump` capture stdout and stderr as inline Evidence. Artifact inspection Evidence records SHA-256 digests before and after the provider call and whether those observed digests differ; this cannot prove the exact bytes consumed if the artifact changes during execution. Choose one artifact operation: `file_headers`, `section_headers`, `symbols`, `relocations`, `dwarf`, `disassemble`, or `disassemble_all`. `disassemble` maps to `-d`; `disassemble_all` maps to `-D` and has different symbol-boundary semantics. The pathless `inspect_with_objdump {"operation":"architectures"}` MCP call or `rea list-objdump-architectures` CLI command maps to `-i` and produces subjectless provider-capability Evidence describing BFD targets compiled into the installed binary, not a guarantee that a target is supported.
Failure to launch the selected executable reports provider unavailability, the
configured command and the host error under `boundary: "provider-launch"`.
Recovery concerns `REA_OBJDUMP_COMMAND`, PATH and execution permission, including
for target-free architecture listing. Artifact admission keeps separate input
and filesystem-read diagnostics. Invalid operation/selector combinations retain
their rejected fields in input issues before artifact reads or provider launch.
For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI flag is `--follow-debug-links`; `--no-follow-debug-links` or `--follow-debug-links=false` keeps the default. A separate `true` or `false` word after the flag is not its value. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema.
For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the installed GNU objdump's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed.
@@ -216,7 +216,14 @@ export class ReverseEngineeringService {
): Promise<Result<Evidence, AnalysisError>> {
const parsed = objdumpInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(new AnalysisInputError("inspect_with_objdump"));
return err(
analysisInputErrorFromIssues(
"inspect_with_objdump",
parsed.error.issues,
rawInput,
{ cause: parsed.error },
),
);
const {
detail,
command_timeout_ms: commandTimeoutMs,
@@ -520,7 +527,10 @@ export class ReverseEngineeringService {
if (
earlyFailure === undefined &&
(capture === undefined ||
(cause instanceof OwnedOutputFailure && cause.launchFailure) ||
code === "ENOENT" ||
code === "EACCES" ||
code === "EPERM" ||
code === "ERR_PROVIDER_LAUNCH")
) {
if (commandCancelled(signal, cause, code))
@@ -534,23 +544,30 @@ export class ReverseEngineeringService {
...ownedCleanupOptions(cause),
...reverseCommandTimeoutGuidance(timeoutMs),
});
else if (code === "EACCES" || code === "EPERM")
earlyFailure = new AnalysisAccessDeniedError(
else {
const provider =
operation === "inspect_with_objdump" ? "objdump" : "rizin";
const setting =
provider === "objdump"
? "REA_OBJDUMP_COMMAND"
: "REA_RIZIN_COMMAND";
earlyFailure = new AnalysisCapabilityUnavailableError(
provider,
operation,
command,
code,
`Unable to run configured command (${String(code ?? "unknown error")})`,
{
cause,
userMessage: `Could not launch the selected ${provider} executable ${JSON.stringify(command)} (${String(code ?? "unknown error")}).`,
remediationAction: `Set ${setting} to an installed executable ${provider === "objdump" ? "GNU objdump" : "Rizin"} command, or make that command available on PATH. Check execute permission on the command and search permission on its parent directories before retrying.`,
diagnostics: {
boundary: "provider-launch",
command,
system_code: code ?? null,
},
...ownedCleanupOptions(cause),
},
);
else
earlyFailure = new AnalysisCapabilityUnavailableError(
operation === "inspect_with_objdump" ? "objdump" : "rizin",
operation,
`Unable to run configured command (${String(code ?? "unknown error")})`,
{ cause, ...ownedCleanupOptions(cause) },
);
}
}
if (earlyFailure === undefined && capture !== undefined) {
stdout = capture.stdout;
@@ -589,12 +606,6 @@ export class ReverseEngineeringService {
...ownedCleanupOptions(cause),
...reverseCommandTimeoutGuidance(timeoutMs),
});
else if (code === "EACCES" || code === "EPERM")
failure = new AnalysisAccessDeniedError(operation, command, code, {
cause,
capturedOutput,
...ownedCleanupOptions(cause),
});
if (
cause instanceof OwnedOutputFailure &&
cause.cleanup !== undefined
@@ -834,6 +845,7 @@ interface OwnedOutputFailureOptions {
readonly cleanup?: AnalysisCleanupObservation;
readonly cleanupOwner?: ProviderProcessSupervisor;
readonly cause?: unknown;
readonly launchFailure?: boolean;
}
class OwnedOutputFailure extends Error {
@@ -850,6 +862,7 @@ class OwnedOutputFailure extends Error {
readonly collectionFailure: boolean;
readonly cleanup: AnalysisCleanupObservation | undefined;
readonly cleanupOwner: ProviderProcessSupervisor | undefined;
readonly launchFailure: boolean;
constructor(options: OwnedOutputFailureOptions) {
super("Owned provider command did not produce a complete result.", {
@@ -868,6 +881,7 @@ class OwnedOutputFailure extends Error {
this.collectionFailure = options.collectionFailure ?? false;
this.cleanup = options.cleanup;
this.cleanupOwner = options.cleanupOwner;
this.launchFailure = options.launchFailure ?? false;
}
}
@@ -954,12 +968,15 @@ const runOwnedCommandOutput =
: launchErrorCode === "E2BIG"
? "E2BIG"
: snapshot === null
? "ERR_PROVIDER_LAUNCH"
? typeof launchErrorCode === "string"
? launchErrorCode
: "ERR_PROVIDER_LAUNCH"
: (snapshot.exitCode ?? null);
throw new OwnedOutputFailure({
stdout: snapshot?.stdout.text ?? "",
stderr: snapshot?.stderr.text ?? "",
code,
launchFailure: cause.reason === "process" && snapshot === null,
signal: snapshot?.signal ?? null,
outputTruncated: snapshot?.diagnosticTruncated === true,
stdoutInvalidUtf8: snapshot?.stdout.invalidUtf8 === true,
@@ -3,6 +3,7 @@ import { expect, it, vi } from "vitest";
import { createArtifact } from "../support/reverseEngineeringCompositionFixtures.js";
import { TestReverseEngineeringService } from "../support/reverseEngineeringService.js";
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
it("keeps omitted and undefined selection out of default command Evidence", async () => {
const { directory, path } = await createArtifact();
@@ -95,6 +96,16 @@ it.each([
ok: false,
error: { _tag: "AnalysisInputError" },
});
if (result.ok) throw new Error("Expected selector rejection");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "invalid_request",
details: {
operation: "inspect_with_objdump",
issues: expect.arrayContaining([
{ path: ["symbol"], reason: "unknown_argument" },
]),
},
});
expect(digest).not.toHaveBeenCalled();
expect(run).not.toHaveBeenCalled();
await service.close();
@@ -0,0 +1,116 @@
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
import { expect, it } from "vitest";
import { OwnedCommandFailure } from "../../src/process/OwnedCommand.js";
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
import {
createArtifact,
providerSnapshot,
} from "../support/reverseEngineeringCompositionFixtures.js";
import { TestReverseEngineeringService } from "../support/reverseEngineeringService.js";
it.each(["ENOENT", "EACCES", "EPERM", "ENOTDIR"] as const)(
"preserves provider-launch %s and executable recovery independently of target admission",
async (code) => {
const { directory, path } = await createArtifact();
const command = resolve(directory, "configured-objdump");
const hostFailure = Object.assign(
new Error("controlled executable launch rejection"),
{ code },
);
const service = new TestReverseEngineeringService({
environment: { REA_OBJDUMP_COMMAND: command },
runOwned: async () => {
throw new OwnedCommandFailure("process", "launch failed", null, null, {
cause: hostFailure,
});
},
});
try {
for (const input of [
{ operation: "architectures" },
{ path, operation: "file_headers" },
]) {
const result = await service.inspectWithObjdump(input);
if (result.ok)
throw new Error("Expected configured provider launch failure");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "capability_unavailable",
retryable: false,
details: {
provider_id: "objdump",
diagnostics: {
boundary: "provider-launch",
command,
system_code: code,
},
},
remediation: {
action: expect.stringContaining("REA_OBJDUMP_COMMAND"),
},
});
expect(projected.message).toContain(command);
expect(projected.message).toContain(code);
expect(projected.remediation.action).toContain("execute permission");
expect(projected.remediation.action).not.toContain("another target");
}
} finally {
await service.close();
await rm(directory, { recursive: true, force: true });
}
},
);
it("preserves target-read permission diagnostics before provider launch", async () => {
let launches = 0;
const path = resolve("read-denied-artifact");
const service = new TestReverseEngineeringService({
stat: async () => {
throw Object.assign(new Error("controlled target read denial"), {
code: "EACCES",
});
},
runOwned: async () => {
launches += 1;
return providerSnapshot();
},
});
try {
const result = await service.inspectWithObjdump({
path,
operation: "file_headers",
});
if (result.ok) throw new Error("Expected target admission failure");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "access_denied",
details: { boundary: "filesystem-read", path, system_code: "EACCES" },
});
expect(launches).toBe(0);
} finally {
await service.close();
}
});
it("retains successful architecture and artifact output for an admitted provider", async () => {
const { directory, path } = await createArtifact();
const service = new TestReverseEngineeringService({
runOwned: async () => providerSnapshot(),
});
try {
for (const input of [
{ operation: "architectures" },
{ path, operation: "file_headers" },
]) {
const result = await service.inspectWithObjdump(input);
if (!result.ok) throw result.error;
expect(result.value.normalized_result).toMatchObject({
stdout: "captured owned output",
completion_status: "complete",
});
}
} finally {
await service.close();
await rm(directory, { recursive: true, force: true });
}
});