diff --git a/src/browser/history/MitmproxyCaptureAdapter.ts b/src/browser/history/MitmproxyCaptureAdapter.ts index a3ebd66f7..2e71720c7 100644 --- a/src/browser/history/MitmproxyCaptureAdapter.ts +++ b/src/browser/history/MitmproxyCaptureAdapter.ts @@ -6,6 +6,8 @@ import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCo import type { HistoricalCaptureFormatAdapter } from "./HistoricalCaptureFormatAdapter.js"; import { MITMPROXY_CAPTURE_PROVIDER_IDENTITY } from "./CaptureRelease.js"; const OPERATION = "inspect_web_network_capture"; +const MITMDUMP_REQUIREMENT = + "Native capture decoding requires Linux and an absolute REA_MITMDUMP_COMMAND path to caller-supplied mitmdump 12.2.3; HAR needs no external engine."; /** Native mitmproxy decoding is offline and uses only an explicitly configured Linux tool. */ export class MitmproxyCaptureAdapter implements HistoricalCaptureFormatAdapter { @@ -25,16 +27,18 @@ export class MitmproxyCaptureAdapter implements HistoricalCaptureFormatAdapter { throw new AnalysisCapabilityUnavailableError( "mitmproxy", OPERATION, - "Native capture decoding requires Linux and an absolute REA_MITMDUMP_COMMAND path to caller-supplied mitmdump 12.2.3; HAR needs no external engine.", + MITMDUMP_REQUIREMENT, + { userMessage: MITMDUMP_REQUIREMENT }, ); try { await access(command, constants.R_OK | constants.X_OK); } catch (cause: unknown) { + const reason = `Configured mitmdump executable is unavailable: ${command}.`; throw new AnalysisCapabilityUnavailableError( "mitmproxy", OPERATION, - `Configured mitmdump executable is unavailable: ${command}.`, - { cause }, + reason, + { cause, userMessage: reason }, ); } return { diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 2316f44a7..c38aefa09 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -52,6 +52,8 @@ import { } from "./EvmWorkerLimits.js"; const OPERATION = "inspect_evm_interface"; +const HOST_REQUIREMENT = + "Initial real-verified offline EVM interface profile supports Linux x64 only."; const replySchema = z.discriminatedUnion("ok", [ z.strictObject({ ok: z.literal(true), @@ -99,7 +101,8 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { throw new AnalysisCapabilityUnavailableError( EVMOLE_PROVIDER_IDENTITY.id, OPERATION, - "Initial real-verified offline EVM interface profile supports Linux x64 only.", + HOST_REQUIREMENT, + { userMessage: HOST_REQUIREMENT }, ); try { if (!isAbsolute(limiter)) diff --git a/src/firmware/FirmwareCommand.test.ts b/src/firmware/FirmwareCommand.test.ts index 1ca439b5f..cbfa0925a 100644 --- a/src/firmware/FirmwareCommand.test.ts +++ b/src/firmware/FirmwareCommand.test.ts @@ -4,10 +4,32 @@ import { join } from "node:path"; import { expect, it as test } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { resolveFirmwareCommand } from "./FirmwareCommand.js"; const it = test.skipIf(process.platform !== "linux"); +test("names the host and tool requirement of an unconfigured engine", async () => { + const refusal: unknown = await resolveFirmwareCommand( + {}, + "binwalk", + "inspect_firmware_regions", + ).catch((cause: unknown) => cause); + + expect(refusal).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + }); + if (!(refusal instanceof AnalysisCapabilityUnavailableError)) + throw new Error("Expected a capability refusal"); + expect(projectAnalysisError(refusal)).toMatchObject({ + code: "capability_unavailable", + message: refusal.reason, + remediation: { action: refusal.reason }, + }); + expect(refusal.reason).toContain("REA_BINWALK_COMMAND"); +}); + it("rejects a directory configured as the firmware engine executable", async () => { const root = await createTestTempDirectory("rea-firmware-command-"); const command = join(root, "binwalk"); diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index 025596528..27bd57aa3 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -29,6 +29,16 @@ export type FirmwareLauncher = ( options: OwnedProviderProcessSpawnOptions, ) => Promise; +/** Refuse an unmet host or tool requirement, which another target cannot satisfy. */ +export const firmwareToolUnavailable = ( + engine: string, + operation: string, + reason: string, +): AnalysisCapabilityUnavailableError => + new AnalysisCapabilityUnavailableError(engine, operation, reason, { + userMessage: reason, + }); + /** Resolve explicitly supplied executable paths; never install or search for engines. */ export const resolveFirmwareCommand = async ( environment: Readonly>, @@ -43,13 +53,13 @@ export const resolveFirmwareCommand = async ( configured === undefined || !isAbsolute(configured) ) - throw new AnalysisCapabilityUnavailableError( + throw firmwareToolUnavailable( engine, operation, `This integration requires Linux and an absolute ${variable} path to the caller-supplied tool`, ); const command = await realpath(configured).catch((cause: unknown) => { - throw new AnalysisCapabilityUnavailableError( + throw firmwareToolUnavailable( engine, operation, `${variable} is unavailable: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -62,7 +72,7 @@ export const resolveFirmwareCommand = async ( throw new TypeError("Configured path is not a regular file"); sha256 = await hashFirmwareFile(command); } catch (cause: unknown) { - throw new AnalysisCapabilityUnavailableError( + throw firmwareToolUnavailable( engine, operation, `${variable} is not a readable executable regular file: ${command}: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -71,7 +81,7 @@ export const resolveFirmwareCommand = async ( const limiter = environment.REA_FIRMWARE_PRLIMIT_COMMAND ?? "/usr/bin/prlimit"; if (!isAbsolute(limiter)) - throw new AnalysisCapabilityUnavailableError( + throw firmwareToolUnavailable( engine, operation, "REA_FIRMWARE_PRLIMIT_COMMAND must be absolute", @@ -81,7 +91,7 @@ export const resolveFirmwareCommand = async ( if (!(await stat(limiter)).isFile()) throw new TypeError("Configured path is not a regular file"); } catch (cause: unknown) { - throw new AnalysisCapabilityUnavailableError( + throw firmwareToolUnavailable( engine, operation, `Resource limiter is not an executable regular file: ${limiter}: ${cause instanceof Error ? cause.message : String(cause)}; provide util-linux prlimit`, diff --git a/src/firmware/FirmwareProvider.ts b/src/firmware/FirmwareProvider.ts index 610c91f1c..a0d7dfa50 100644 --- a/src/firmware/FirmwareProvider.ts +++ b/src/firmware/FirmwareProvider.ts @@ -12,7 +12,6 @@ import { import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, - AnalysisCapabilityUnavailableError, AnalysisInputError, AnalysisOutputError, AnalysisTimeoutError, @@ -29,6 +28,7 @@ import { } from "./FirmwareRelease.js"; import { admitFirmwareVersion } from "./FirmwareVersion.js"; import { + firmwareToolUnavailable, resolveFirmwareCommand, runFirmwareCommand, readFirmwareCommandReport, @@ -144,14 +144,14 @@ export class FirmwareProvider implements FirmwareAnalysisPort { const versionRun = await run(["--version"]); const version = versionRun.stdout.text.trim(); if (versionRun.stdout.bytes > 1024) - throw new AnalysisCapabilityUnavailableError( + throw firmwareToolUnavailable( engineName, request.operation, `Tool version banner is ${String(versionRun.stdout.bytes)} bytes; expected a short ${engineName} version line.`, ); const admitted = admitFirmwareVersion(engineName, version); if (admitted.status === "unsupported" || admitted.status === "unresolved") - throw new AnalysisCapabilityUnavailableError( + throw firmwareToolUnavailable( engineName, request.operation, admitted.message, diff --git a/src/javascript/recovery/WakaruCommand.test.ts b/src/javascript/recovery/WakaruCommand.test.ts index bdfe54dd9..08e43e44a 100644 --- a/src/javascript/recovery/WakaruCommand.test.ts +++ b/src/javascript/recovery/WakaruCommand.test.ts @@ -1,9 +1,30 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { expect, it } from "vitest"; -import { runWakaruCommand, type WakaruLauncher } from "./WakaruCommand.js"; +import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { + resolveWakaruCommand, + runWakaruCommand, + type WakaruLauncher, +} from "./WakaruCommand.js"; import { RECOVERY_LIMITS } from "./WakaruRelease.js"; +it("names the Wakaru configuration requirement when it is not configured", async () => { + const refusal: unknown = await resolveWakaruCommand({}).catch( + (cause: unknown) => cause, + ); + + if (!(refusal instanceof AnalysisCapabilityUnavailableError)) + throw new Error("Expected a capability refusal"); + expect(refusal.reason).toContain("REA_WAKARU_COMMAND"); + expect(projectAnalysisError(refusal)).toMatchObject({ + code: "capability_unavailable", + message: refusal.reason, + remediation: { action: refusal.reason }, + }); +}); + it("rejects an exhausted shared execution budget before launching the next command", async () => { await expect( runWakaruCommand({ diff --git a/src/javascript/recovery/WakaruCommand.ts b/src/javascript/recovery/WakaruCommand.ts index 42062feee..41e11d55f 100644 --- a/src/javascript/recovery/WakaruCommand.ts +++ b/src/javascript/recovery/WakaruCommand.ts @@ -25,6 +25,8 @@ import { import { RECOVERY_LIMITS } from "./WakaruRelease.js"; const OPERATION = "recover_javascript_sources"; +const WAKARU_REQUIREMENT = + "Provide an absolute REA_WAKARU_COMMAND pointing to Wakaru 1.13.0 on Linux x64; no tool is installed by REA"; /** Inject the owned process launcher while retaining production protocol parsing. */ export type WakaruLauncher = ( @@ -45,7 +47,8 @@ export const resolveWakaruCommand = async ( throw new AnalysisCapabilityUnavailableError( "wakaru", OPERATION, - "Provide an absolute REA_WAKARU_COMMAND pointing to Wakaru 1.13.0 on Linux x64; no tool is installed by REA", + WAKARU_REQUIREMENT, + { userMessage: WAKARU_REQUIREMENT }, ); try { const command = await realpath(configured); @@ -61,12 +64,11 @@ export const resolveWakaruCommand = async ( await access(limiter, constants.X_OK); return { command, limiter, ...fingerprint }; } catch (cause: unknown) { - throw new AnalysisCapabilityUnavailableError( - "wakaru", - OPERATION, - `Configured Wakaru or util-linux prlimit is unavailable: ${recoveryFailureMessage(cause)}`, - { cause }, - ); + const reason = `Configured Wakaru or util-linux prlimit is unavailable: ${recoveryFailureMessage(cause)}`; + throw new AnalysisCapabilityUnavailableError("wakaru", OPERATION, reason, { + cause, + userMessage: reason, + }); } }; diff --git a/src/javascript/recovery/WakaruExecution.ts b/src/javascript/recovery/WakaruExecution.ts index ff82b5c47..1a4c72067 100644 --- a/src/javascript/recovery/WakaruExecution.ts +++ b/src/javascript/recovery/WakaruExecution.ts @@ -69,12 +69,12 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => { if ( version.exit_code !== 0 || version.stdout.trim() !== `wakaru ${WAKARU_RELEASE.version}` - ) - throw new AnalysisCapabilityUnavailableError( - "wakaru", - OPERATION, - `This adapter requires Wakaru ${WAKARU_RELEASE.version}; reported version: ${version.stdout.trim()}; exit: ${String(version.exit_code)}; stderr: ${version.stderr}`, - ); + ) { + const reason = `This adapter requires Wakaru ${WAKARU_RELEASE.version}; reported version: ${version.stdout.trim()}; exit: ${String(version.exit_code)}; stderr: ${version.stderr}`; + throw new AnalysisCapabilityUnavailableError("wakaru", OPERATION, reason, { + userMessage: reason, + }); + } const run = await runWakaruCommand({ ...commandContext, staging, diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index e08bb8fac..2f99d026e 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -50,6 +50,8 @@ import { const OPERATION = "observe_native_calls"; const PROVIDER = "native-macos"; +const LLDB_REQUIREMENT = + "LLDB from Xcode or the Command Line Tools is not available"; /** Captured target output kept per stream; the rest is counted, not stored. */ const MAX_OUTPUT_BYTES = 1024 * 1024; /** LLDB startup, symbol loading and teardown beyond the observation window. */ @@ -180,7 +182,8 @@ export class LldbCallTracer implements NativeCallTracer { : new AnalysisCapabilityUnavailableError( PROVIDER, OPERATION, - "LLDB from Xcode or the Command Line Tools is not available", + LLDB_REQUIREMENT, + { userMessage: LLDB_REQUIREMENT }, ), ); const root = await PrivateRuntimeRoot.create({ prefix: "rea-lldb-" }); diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index d011509d6..c358f2d2c 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -668,13 +668,15 @@ const translateCommandFailure = ( ...(capturedOutput === undefined ? {} : { capturedOutput }), ...(cleanup === undefined ? {} : { cleanup }), }; - if (failure.reason === "unavailable") + if (failure.reason === "unavailable") { + const reason = `${failure.tool} is unavailable through xcrun.`; return new AnalysisCapabilityUnavailableError( IDENTITY.id, operation, - `${failure.tool} is unavailable through xcrun.`, - errorOptions, + reason, + { ...errorOptions, userMessage: reason }, ); + } if (failure.reason === "cancelled") return new AnalysisCancelledError(operation, errorOptions); if (failure.reason === "timeout") diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index eacc2d63e..4c1add09c 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -38,6 +38,9 @@ import { PWNTOOLS_LIMITS, } from "./PwntoolsRelease.js"; +const HOST_REQUIREMENT = + "The initial real-verified pwntools artifact profile supports Linux x64 only."; + /** Owned launcher seam shared by the offline pwntools artifact adapters. */ export type PwntoolsLauncher = NonNullable< Parameters[2] @@ -122,7 +125,8 @@ export class PwntoolsDecoder< throw new AnalysisCapabilityUnavailableError( this.identity.id, OPERATION, - "The initial real-verified pwntools artifact profile supports Linux x64 only.", + HOST_REQUIREMENT, + { userMessage: HOST_REQUIREMENT }, ); if (!isAbsolute(selectedPath)) throw pwntoolsUnavailable( diff --git a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts index 702458224..71b7383ca 100644 --- a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts @@ -591,12 +591,18 @@ it.runIf(!unsupportedHost)( it.runIf(unsupportedHost)( "reports unverified host coverage before acquiring a provider", async () => { - expect( - await new PwntoolsLayoutProvider({}).inspect({ path: "/selected.elf" }), - ).toMatchObject({ + const result = await new PwntoolsLayoutProvider({}).inspect({ + path: "/selected.elf", + }); + expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisCapabilityUnavailableError" }, }); + // Another target cannot satisfy a host requirement, so name it. + if (result.ok) throw new Error("Expected a host refusal"); + const projected = projectAnalysisError(result.error); + expect(projected.message).toContain("Linux x64"); + expect(projected.remediation.action).toContain("Linux x64"); }, ); diff --git a/tests/boundary/browser/historicalWebCapture.test.ts b/tests/boundary/browser/historicalWebCapture.test.ts index a6905bdb3..a0a9f10e2 100644 --- a/tests/boundary/browser/historicalWebCapture.test.ts +++ b/tests/boundary/browser/historicalWebCapture.test.ts @@ -79,6 +79,7 @@ it("keeps malformed JSON separate from absent external mitmproxy capability", as expect(missing.ok).toBe(false); expect(JSON.parse(missing.stdout)).toMatchObject({ category: "unsupported_provider", + message: expect.stringContaining("REA_MITMDUMP_COMMAND"), }); }); diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts index dd0e0fd8e..5d1305d21 100644 --- a/tests/boundary/evm/evmoleInterfaceProvider.test.ts +++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts @@ -29,6 +29,21 @@ const fixture = async () => { return { path, root: workspace.root }; }; +it.runIf(unsupportedHost)( + "names the host requirement instead of asking for another target", + async () => { + const result = await new EvmoleInterfaceProvider({}).inspect({ + path: "/selected.hex", + encoding: "hex", + }); + if (result.ok) throw new Error("Expected a host refusal"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ code: "capability_unavailable" }); + expect(projected.message).toContain("Linux x64"); + expect(projected.remediation.action).toContain("Linux x64"); + }, +); + it.runIf(!unsupportedHost).each(["SIGXCPU", "SIGXFSZ", "EFBIG"])( "preserves %s with configured limits distinct from unknown effective limits", async (failure) => { diff --git a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts index aa8a932fd..d65b820e2 100644 --- a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts +++ b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts @@ -10,7 +10,8 @@ import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjec import { nativeCallObservationInputSchema } from "../../../../src/domain/native/nativeCallObservation.js"; import type { NativeCallTracer } from "../../../../src/native/LldbCallTracer.js"; import { LldbCallTracer } from "../../../../src/native/LldbCallTracer.js"; -import { ok } from "../../../../src/domain/result.js"; +import { err, ok } from "../../../../src/domain/result.js"; +import { NativeCommandFailure } from "../../../../src/native/CommandRunner.js"; const EVENT = { sequence: 0, @@ -106,6 +107,19 @@ const fixtureTracer = ( }; describe("LLDB failure retention through the production tracer", () => { + it("names the missing LLDB requirement instead of asking for another target", async () => { + const tracer = new LldbCallTracer( + () => Promise.reject(new Error("LLDB must not launch")), + async (tool) => err(new NativeCommandFailure(tool, "unavailable")), + ); + const result = await tracer.trace(request); + if (result.ok) throw new Error("expected an unavailable LLDB"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "capability_unavailable", + message: "LLDB from Xcode or the Command Line Tools is not available", + }); + }); + it("preserves cancellation and journal/output evidence after runtime cleanup", async () => { const fixture = fixtureTracer("cancelled"); const result = await fixture.tracer.trace(request); diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index 9260e7fd1..2b26733b0 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -657,6 +657,10 @@ describe("native macOS provider failures and parsing", () => { expect(!unavailableResult.ok && unavailableResult.error._tag).toBe( "AnalysisCapabilityUnavailableError", ); + if (unavailableResult.ok) throw new Error("Expected an unavailable tool"); + expect(projectAnalysisError(unavailableResult.error).message).toBe( + "lipo is unavailable through xcrun.", + ); const malformed = new NativeMacOSProvider( new FixtureRunner({ lipo: "malformed" }),