mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(objdump): preserve input issues and provider launch failures
This commit is contained in:
1 parent
c86288db1b
commit
76381b29d4
4 files changed
+170
-19
No files matched your search
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user