diff --git a/src/pcap/PcapCommand.ts b/src/pcap/PcapCommand.ts index a0833f5992..0d28a3d752 100644 --- a/src/pcap/PcapCommand.ts +++ b/src/pcap/PcapCommand.ts @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, - AnalysisCapabilityUnavailableError, AnalysisProtocolError, AnalysisResourceConstraintError, AnalysisTimeoutError, @@ -19,6 +18,7 @@ import { windowsBatchCommand, WindowsBatchCommandError, } from "../process/WindowsBatchCommand.js"; +import { pcapToolUnavailable } from "./PcapConfiguration.js"; export interface PcapCommandLimits { readonly timeoutMs: number; @@ -168,8 +168,7 @@ export const runPcapCommand = async (options: { "code" in cause && (cause.code === "ENOENT" || cause.code === "EACCES")) ) - throw new AnalysisCapabilityUnavailableError( - "pcap", + throw pcapToolUnavailable( operation, `Cannot run selected tool ${command}: ${cause.message}`, details, @@ -181,8 +180,7 @@ export const runPcapCommand = async (options: { ? cause.message : String(cause); if (options.inputPath === undefined) - throw new AnalysisCapabilityUnavailableError( - "pcap", + throw pcapToolUnavailable( operation, `The selected tool ${command} cannot report its version: ${diagnostic}`, details, diff --git a/src/pcap/PcapConfiguration.ts b/src/pcap/PcapConfiguration.ts index 8f75b4602a..b2a6212d19 100644 --- a/src/pcap/PcapConfiguration.ts +++ b/src/pcap/PcapConfiguration.ts @@ -2,12 +2,31 @@ import { isAbsolute } from "node:path"; import type { ProviderAvailability } from "../application/AnalysisProvider.js"; import type { ProviderRejectionCode } from "../contracts/providerSelection.js"; +import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js"; +import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; /** Environment keys selecting the caller's tshark and capinfos binaries. */ export const TSHARK_COMMAND_ENV = "REA_TSHARK_COMMAND"; export const CAPINFOS_COMMAND_ENV = "REA_CAPINFOS_COMMAND"; +const TOOL_REMEDIATION = `Supply Wireshark 4.x tshark and capinfos on PATH or through absolute ${TSHARK_COMMAND_ENV} and ${CAPINFOS_COMMAND_ENV}; REA does not install them.`; + +/** + * Host-tool refusals concern the caller's Wireshark installation, not the + * target, so they carry their own guidance instead of the generic advice to + * choose another target. + */ +export const pcapToolUnavailable = ( + operation: string, + reason: string, + options?: AnalysisErrorOptions, +): AnalysisCapabilityUnavailableError => + new AnalysisCapabilityUnavailableError("pcap", operation, reason, { + ...options, + userMessage: `${/[.!?]$/u.test(reason) ? reason : `${reason}.`} ${TOOL_REMEDIATION}`, + }); + /** How each tool was selected for this provider instance. */ export interface PcapToolSelection { readonly tshark: { command: string; source: "environment" | "path" }; diff --git a/src/pcap/PcapNativeBound.ts b/src/pcap/PcapNativeBound.ts index 172eaba91e..dc98bfca07 100644 --- a/src/pcap/PcapNativeBound.ts +++ b/src/pcap/PcapNativeBound.ts @@ -6,6 +6,8 @@ import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore. import type { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js"; import type { runPcapCommand } from "./PcapCommand.js"; +const PRLIMIT_COMMAND_ENV = "REA_TSHARK_PRLIMIT_COMMAND"; + /** Own native capture admission and the exact enforced resource profile. */ export const preparePcapNativeBound = async ( environment: Readonly>, @@ -22,17 +24,20 @@ export const preparePcapNativeBound = async ( ], }; } - if (process.platform !== "linux") + if (process.platform !== "linux") { + const reason = + "Packet summaries require an enforceable native memory bound: Linux with util-linux prlimit, or Windows with owned Job Objects. This host's capture boundary remains unsupported."; throw new AnalysisCapabilityUnavailableError( "pcap", "inspect_pcap_capture", - "Packet summaries require an enforceable native memory bound: Linux with util-linux prlimit, or Windows with owned Job Objects. This host's capture boundary remains unsupported.", + reason, + { userMessage: reason }, ); - const configured = - environment.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit"; + } + const configured = environment[PRLIMIT_COMMAND_ENV] ?? "/usr/bin/prlimit"; try { if (!isAbsolute(configured)) - throw new Error("REA_TSHARK_PRLIMIT_COMMAND must be absolute."); + throw new Error(`${PRLIMIT_COMMAND_ENV} must be absolute.`); const limiter = await realpath(configured); await access(limiter, constants.X_OK); const limits = await resources.readLimits({ @@ -58,11 +63,16 @@ export const preparePcapNativeBound = async ( return { nativeBound, limitations }; } catch (cause) { if (cause instanceof AnalysisError) throw cause; + const reason = + "Cannot establish the selected Linux resource limiter before processing capture bytes."; throw new AnalysisCapabilityUnavailableError( "pcap", "inspect_pcap_capture", - "Cannot establish the selected Linux resource limiter before processing capture bytes.", - { cause }, + reason, + { + cause, + userMessage: `${reason} (${cause instanceof Error ? cause.message : String(cause)}) Check that ${PRLIMIT_COMMAND_ENV} (default /usr/bin/prlimit) names an absolute, executable util-linux prlimit.`, + }, ); } }; diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts index 7228d694fd..be2c12ed2b 100644 --- a/src/pcap/PcapProvider.ts +++ b/src/pcap/PcapProvider.ts @@ -25,6 +25,7 @@ import { inspectPcapClient, resolvePcapTools, pcapConfigurationAvailability, + pcapToolUnavailable, type PcapToolSelection, } from "./PcapConfiguration.js"; import { @@ -346,12 +347,9 @@ export class PcapProvider { : signal?.aborted ? new AnalysisCancelledError(request.operation, { cause }) : cause instanceof PcapConfigurationFailure - ? new AnalysisCapabilityUnavailableError( - "pcap", - request.operation, - cause.message, - { cause }, - ) + ? pcapToolUnavailable(request.operation, cause.message, { + cause, + }) : new AnalysisProtocolError( `Packet-capture analysis failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }, diff --git a/src/pcap/PcapSnapshot.ts b/src/pcap/PcapSnapshot.ts index 08f1ea5525..2e9082ccdd 100644 --- a/src/pcap/PcapSnapshot.ts +++ b/src/pcap/PcapSnapshot.ts @@ -43,7 +43,7 @@ export class PcapSnapshot { "pcap", OPERATION, cause.message, - { cause }, + { cause, retryable: cause.retryable }, ); throw new AnalysisResourceConstraintError( OPERATION, diff --git a/tests/boundary/cli/pcapCli.test.ts b/tests/boundary/cli/pcapCli.test.ts index 26e253455d..1eb61cdcff 100644 --- a/tests/boundary/cli/pcapCli.test.ts +++ b/tests/boundary/cli/pcapCli.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "vitest"; -import { serveCli } from "./cliServeHarness.js"; +import { expectConfigurationRefusal, serveCli } from "./cliServeHarness.js"; it.each(["inspect-pcap-client", "inspect-pcap-capture"])( "%s advertises a schema through the public CLI", @@ -11,13 +11,27 @@ it.each(["inspect-pcap-client", "inspect-pcap-capture"])( }, ); -it("reports missing tools with a capability refusal", async () => { - const result = await serveCli( - ["inspect-pcap-capture", "/nonexistent/capture.pcap", "--json"], - { - REA_TSHARK_COMMAND: "/nonexistent/tshark", - REA_CAPINFOS_COMMAND: "/nonexistent/capinfos", - }, +it.each([ + ["inspect-pcap-client"], + ["inspect-pcap-capture", "/nonexistent/capture.pcap"], +])("%s names the missing Wireshark tool to configure", async (...argv) => { + const result = await serveCli([...argv, "--json"], { + REA_TSHARK_COMMAND: "/nonexistent/tshark", + REA_CAPINFOS_COMMAND: "/nonexistent/capinfos", + }); + expectConfigurationRefusal( + result, + "Cannot run selected tool /nonexistent/tshark", + ); + expectConfigurationRefusal(result, "through absolute REA_TSHARK_COMMAND"); +}); + +it("names a relative tool selection as configuration to fix", async () => { + const result = await serveCli(["inspect-pcap-client", "--json"], { + REA_TSHARK_COMMAND: "tshark", + }); + expectConfigurationRefusal( + result, + "REA_TSHARK_COMMAND must be an absolute path to the tshark binary", ); - expect(result.stdout).toContain("capability_unavailable"); }); diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts index 50204a4797..fcb299ade7 100644 --- a/tests/boundary/pcap/pcapProvider.test.ts +++ b/tests/boundary/pcap/pcapProvider.test.ts @@ -246,12 +246,37 @@ it("reports relative configuration and missing capinfos as capability gaps", asy input: {}, }); if (result.ok) throw new Error("expected unavailable tools"); - expect(projectAnalysisError(result.error).code).toBe( - "capability_unavailable", + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + expect(projected.message).toContain( + "Supply Wireshark 4.x tshark and capinfos", ); } }); +it("names the prlimit selection when the Linux limiter is unusable", async () => { + const root = await createTestTempDirectory("rea-pcap-prlimit-"); + const tools = await writeStubTools(root); + const provider = new PcapProvider({ + environment: { + REA_TSHARK_COMMAND: tools.tshark, + REA_CAPINFOS_COMMAND: tools.capinfos, + REA_TSHARK_PRLIMIT_COMMAND: "relative/prlimit", + }, + }); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "inspect_pcap_capture", + input: { path: "/not-opened/capture.pcap" }, + }); + if (result.ok) throw new Error("expected an unusable limiter"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + expect(projected.message).toContain( + "(REA_TSHARK_PRLIMIT_COMMAND must be absolute.) Check that REA_TSHARK_PRLIMIT_COMMAND", + ); +}); + const nodeTool = async ( root: string, name: string, diff --git a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts index 3d7f28b540..c19e682fe9 100644 --- a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts +++ b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts @@ -87,17 +87,22 @@ it.runIf(hasNativeBound)( }, ); -it("rejects a path that is not a readable file", async () => { - const result = await providerOver().execute({ - operation: "inspect_pcap_capture", - input: { path: "/nonexistent/definitely-missing.pcap" }, - }); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "invalid_request", - }); -}); +// Hosts without a native bound refuse before touching the path; that order +// is pinned by the unsupported-boundary test below. +it.runIf(hasNativeBound)( + "rejects a path that is not a readable file", + async () => { + const result = await providerOver().execute({ + operation: "inspect_pcap_capture", + input: { path: "/nonexistent/definitely-missing.pcap" }, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "invalid_request", + }); + }, +); it("reports a missing binary as a capability gap", async () => { const provider = new PcapProvider({ @@ -136,9 +141,13 @@ it.runIf(!hasNativeBound)( input: { path: "/not-opened/capture.pcap" }, }); expect(result.ok).toBe(false); - if (!result.ok) - expect(projectAnalysisError(result.error)).toMatchObject({ - code: "capability_unavailable", - }); + if (result.ok) return; + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("capability_unavailable"); + // The refusal concerns this host, so it must not suggest another target. + expect(projected.message).toContain( + "Linux with util-linux prlimit, or Windows with owned Job Objects", + ); + expect(projected.remediation.action).toBe(projected.message); }, );