diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index 9600192550..51483d5cb0 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -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. diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index 6bcfd534e5..23a9613f77 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -216,7 +216,14 @@ export class ReverseEngineeringService { ): Promise> { 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, diff --git a/tests/composition/objdumpSelectedDisassembly.test.ts b/tests/composition/objdumpSelectedDisassembly.test.ts index 2973a32955..c0d81e0861 100644 --- a/tests/composition/objdumpSelectedDisassembly.test.ts +++ b/tests/composition/objdumpSelectedDisassembly.test.ts @@ -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(); diff --git a/tests/composition/reverseProviderLaunchErrors.test.ts b/tests/composition/reverseProviderLaunchErrors.test.ts new file mode 100644 index 0000000000..5cd951a8ba --- /dev/null +++ b/tests/composition/reverseProviderLaunchErrors.test.ts @@ -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 }); + } +});