From 92a9591a418735dd54169961c42c5ac436107ec0 Mon Sep 17 00:00:00 2001 From: Aaditya Date: Fri, 23 Oct 2026 00:04:14 +0530 Subject: [PATCH] refactor(pcap): give TShark selection and version admission one owner (#3407) * refactor(pcap): give TShark selection and version admission one owner The pcap and packet-capture families wrapped the same caller-supplied TShark with diverging rules. inspect_pcap_* accepted an absolute REA_TSHARK_COMMAND or tshark from PATH, while inspect_packet_* and the server availability policy required an absolute REA_TSHARK_COMMAND and parsed the version banner with a separate regex. PcapConfiguration now owns the rule: selectWiresharkTool selects an absolute environment path or the bare name on PATH and refuses relative values; locateWiresharkTool resolves a PATH selection to the first absolute PATH entry holding an executable file; parseWiresharkVersion admits Wireshark 4.x banners for both providers. TsharkCaptureProvider and the session availability policy consume these facts instead of reinterpreting REA_TSHARK_COMMAND, so all four tools accept and refuse the same configurations. inspect_packet_* still resolves, hashes and rechecks the located executable, and records selection_source in its tool Evidence. Host support and resource bounds are unchanged; unifying them is the next milestone in #3403. Refs #3403 * fix(pcap): execute the TShark the shared locator selects resolvePcapTools still returned bare tool names for PATH selections, so the operating system could run a tool from a relative PATH entry while the packet tools skipped that entry and selected a different install. On POSIX hosts, resolve PATH selections through locateWiresharkTool and run that absolute executable, failing with the existing tshark_missing or capinfos_missing code when no absolute PATH entry holds the tool. Both selections are validated before PATH is consulted, so a relative environment value still reports its own configuration error. Windows keeps PATH/PATHEXT resolution in the process launcher. Refs #3403 * chore: restore main's copies of files the merge hook reformatted The pre-commit oxlint --fix touched two files this PR does not change; keep them identical to main. Co-Authored-By: Claude Opus 5.5 * fix(pcap): share selected tool identity verification * perf(pcap): retain compact executable identity * fix(pcap): report missing selected tools clearly * fix(pcap): read historical capture evidence * fix(pcap): preserve executable read failures * test(pcap): cover version refusal and packet-tool availability selection Address review follow-ups on the shared TShark owner: - Send a 3.x TShark banner and a non-Wireshark banner through the packet provider, pinning the capability-unavailable reason for each and that only the version probe ran before refusal. - Cover the session availability rule for inspect_packet_capture over MCP: a PATH-only install and an absolute REA_TSHARK_COMMAND are available on Linux, a relative REA_TSHARK_COMMAND is refused even when PATH holds TShark, and no absolute PATH entry reports provider_missing. - Rename the locator test's missing absolute PATH entry so it no longer shadows node:path relative. Refs #3403 * fix(pcap): name the configured path when a selected tool is missing Resolving the selected executable before launch replaced the runner's "Cannot run selected tool " refusal with a message that kept the configured path only in diagnostics, so the public CLI no longer told the caller which REA_TSHARK_COMMAND or REA_CAPINFOS_COMMAND value to fix and the pcap CLI boundary test failed. Include the configured command in the capability-unavailable message and assert it there. Refs #3403 * test(captures): give the late-cancellation fixture a TShark selection source The service fixture added on main in 719d56ebf predates the required tool.selection_source field, so its report failed output validation before reaching the late-cancellation path the test covers. Refs #3403 * fix(pcap): report a probe's pending cleanup when a post-run check fails When a tool probe returned with its owned process still awaiting cleanup, a failed post-run executable re-read or identity check threw before run() reported that cleanup. Probes have no snapshot to carry it, so callers saw an ordinary error with cleanupIncomplete false while the provider kept the process and refused the next request. After the command returns, combine such a failure with the probe's ProviderCleanupError (cancellation and other command failures keep their own cleanup outcome). Classify request failures through one analysisFailure owner shared by run() and executeAdmitted(), and let the availability probe rethrow any cleanup-incomplete failure instead of reporting the tool unavailable. Refs #3403 * fix(pcap): keep a failed executable re-read's cleanup live with the probe's The combined post-run failure was bound only to the probe's pending process cleanup. When the executable re-read had also failed to close its file, the classified failure owned that cleanup, but the combined error's live cleanupResources omitted the file and could clear cleanupIncomplete once the process stopped. Bind both the classified failure and the probe cleanup as dependencies. Refs #3403 --------- Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/packet-captures.md | 11 +- docs/pcap-analysis.md | 11 +- src/application/CapabilityInventory.ts | 2 +- .../captures/PacketCaptureService.test.ts | 1 + src/captures/TsharkCaptureProvider.ts | 45 ++-- .../captures/captureToolContracts.ts | 4 +- .../analysisView/pcapCaptureView.test.ts | 17 ++ src/domain/analysisView/pcapCaptureView.ts | 4 +- src/domain/captures/packetCapture.ts | 5 + src/domain/pcap/pcapAnalysis.ts | 27 ++ src/pcap/PcapConfiguration.test.ts | 133 +++++++++- src/pcap/PcapConfiguration.ts | 186 ++++++++++---- src/pcap/PcapProvider.ts | 239 +++++++++++++++--- src/server/createServer.ts | 21 +- .../boundary/captures/captureProvider.test.ts | 69 ++++- tests/boundary/cli/pcapCli.test.ts | 2 +- .../mcp/packetCaptureAvailability.test.ts | 104 ++++++++ tests/boundary/mcp/pcapMcp.test.ts | 6 + tests/boundary/pcap/pcapProvider.test.ts | 41 ++- .../pcap/pcapProviderCancellation.test.ts | 136 +++++++++- .../pcap/pcapProviderCrossPlatform.test.ts | 107 +++++++- 21 files changed, 1040 insertions(+), 131 deletions(-) create mode 100644 tests/boundary/mcp/packetCaptureAvailability.test.ts diff --git a/docs/packet-captures.md b/docs/packet-captures.md index 8db0973865..077eb4384c 100644 --- a/docs/packet-captures.md +++ b/docs/packet-captures.md @@ -10,10 +10,17 @@ Supply an existing TShark 4.x installation **on Linux**, with util-linux `prlimit` available at `/usr/bin/prlimit`. Override its absolute path with `REA_TSHARK_PRLIMIT_COMMAND` if required. Unsupported hosts, including macOS and Windows, are refused before inspection because this integration requires -an enforced address-space limit: +an enforced address-space limit. + +TShark selection follows the same rule as [`inspect_pcap_capture`](pcap-analysis.md): +an absolute `REA_TSHARK_COMMAND`, otherwise `tshark` from the first absolute +`PATH` entry that holds an executable file. A relative `REA_TSHARK_COMMAND` is +refused rather than looked up. Either way the selected executable is resolved, +hashed and rechecked after each run, and Evidence records its +`selection_source` (`environment` or `path`): ```sh -export REA_TSHARK_COMMAND=/absolute/path/to/tshark +export REA_TSHARK_COMMAND=/absolute/path/to/tshark # optional when tshark is on PATH rea inspect-packet-capture /artifacts/device.pcapng \ --fields ip.addr --fields udp.port --fields data.data \ --display_filter 'udp' --format json diff --git a/docs/pcap-analysis.md b/docs/pcap-analysis.md index 58c5dcd037..5749fa3294 100644 --- a/docs/pcap-analysis.md +++ b/docs/pcap-analysis.md @@ -17,7 +17,16 @@ rea inspect-pcap-capture /captures/session.pcapng --json ``` Without explicit tool commands, this family resolves them through PATH and -records that selection source. Client inspection reads no capture file. Capture +records that selection source. On Linux and macOS it runs the tool found in the +first absolute PATH entry holding an executable file, the same selection the +[packet capture tools](packet-captures.md) make; relative PATH entries are +skipped. Windows keeps PATH/PATHEXT resolution in the process launcher. Client +inspection reads no capture file; on hosts where PATH/PATHEXT does not expose +an absolute candidate, its executable digest and resolved path are reported as +unresolved. Otherwise the canonical executable path and SHA-256 are reported. +The pcap provider and packet tools share the selected-tool identity check, and +changes to a resolved executable during a run fail with artifact-changed +Evidence. Capture inspection requires Linux with util-linux prlimit or Windows with owned Job Objects. macOS capture inspection returns a capability gap because the new native memory boundary is unverified; its version probes remain available. diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index ba4c8f7d13..2f9abadfbd 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -482,7 +482,7 @@ const workflowAvailabilityFor = ({ : { reason: "provider_missing", remediation: - "On Linux, supply TShark 4.x via absolute REA_TSHARK_COMMAND and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.", + "On Linux, supply TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND, and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.", }; if (name === "decode_signal_recording" || name === "inspect_signal_decoders") return policy.signalRecordingEnabled === true diff --git a/src/application/captures/PacketCaptureService.test.ts b/src/application/captures/PacketCaptureService.test.ts index 3cd1521d09..d608ebc2e7 100644 --- a/src/application/captures/PacketCaptureService.test.ts +++ b/src/application/captures/PacketCaptureService.test.ts @@ -37,6 +37,7 @@ it("retains provider metadata in a late-cancellation observation", async () => { diagnostics: "", tool: { configured_path: "/fixture/tshark", + selection_source: "environment", path: "/fixture/tshark", sha256: "b".repeat(64), version_banner: "TShark fixture", diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index 4f88314980..2aadaef049 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -60,6 +60,14 @@ import { OwnedCommandFailure, } from "../process/OwnedCommand.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { + TSHARK_COMMAND_ENV, + assertWiresharkExecutableIdentity, + wiresharkExecutableIdentity, + locateWiresharkTool, + parseWiresharkVersion, + selectWiresharkTool, +} from "../pcap/PcapConfiguration.js"; import { privateWorkspaceFailure } from "../process/privateWorkspaceFailure.js"; const OPERATION = "inspect_packet_capture"; @@ -143,18 +151,22 @@ export class TsharkCaptureProvider implements PacketCapturePort { await this.#resources.release(); if (this.platform !== "linux") throw new Error( - "Packet inspection requires Linux with util-linux prlimit and REA_TSHARK_COMMAND so expanded captures are processed under an enforced address-space limit.", + "Packet inspection requires Linux with util-linux prlimit and TShark 4.x (on PATH or through absolute REA_TSHARK_COMMAND) so expanded captures are processed under an enforced address-space limit.", + ); + const selection = selectWiresharkTool(this.environment, "tshark"); + const configured = locateWiresharkTool(selection, this.environment); + if (configured === undefined) + throw new Error( + `TShark 4.x was not found on PATH; add it to PATH or set absolute ${TSHARK_COMMAND_ENV}.`, ); - const configured = this.environment.REA_TSHARK_COMMAND ?? ""; - if (!isAbsolute(configured)) - throw new Error("Supply absolute REA_TSHARK_COMMAND for TShark 4.x."); const command = await realpath(configured); await access(command, constants.X_OK); - const tool = await this.#resources.read( + const toolSnapshot = await this.#resources.read( command, 64 * 1024 * 1024, options?.signal, ); + const tool = wiresharkExecutableIdentity(toolSnapshot); const configuredLimiter = this.environment.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit"; if (!isAbsolute(configuredLimiter)) @@ -224,13 +236,13 @@ export class TsharkCaptureProvider implements PacketCapturePort { "Configured resource limiter changed during analysis.", { capturedOutput }, ); - if (current.sha256 !== tool.sha256) - throw new AnalysisArtifactChangedError( - operation, - command, - "Configured TShark executable changed during analysis.", - { capturedOutput }, - ); + assertWiresharkExecutableIdentity( + operation, + command, + tool, + wiresharkExecutableIdentity(current), + { capturedOutput }, + ); phase = previousPhase; resourcePath = "path" in input ? input.path : command; if (output.exitCode !== 0) @@ -249,11 +261,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { }; phase = "configuration"; const version = await execute(["--version"]); - if ( - !/^TShark \(Wireshark\) 4\.[0-9]+\.[0-9]+(?:[ .(\r\n]|$)/u.test( - version.stdout.text, - ) - ) + if (parseWiresharkVersion(version.stdout.text, "tshark") === null) throw new Error( "Expected TShark 4.x; inspect the selected installation.", ); @@ -261,6 +269,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { diagnostics, tool: { configured_path: configured, + selection_source: selection.source, path: command, sha256: tool.sha256, version_banner: version.stdout.text, @@ -538,7 +547,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { ...(observedOutput === undefined ? {} : { capturedOutput: observedOutput }), - userMessage: `${reason} On Linux, configure TShark 4.x through REA_TSHARK_COMMAND and util-linux prlimit through REA_TSHARK_PRLIMIT_COMMAND (default /usr/bin/prlimit).`, + userMessage: `${reason} On Linux, supply TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND, and util-linux prlimit through REA_TSHARK_PRLIMIT_COMMAND (default /usr/bin/prlimit).`, }, ); if ( diff --git a/src/contracts/captures/captureToolContracts.ts b/src/contracts/captures/captureToolContracts.ts index dca211511a..bf4cdbe30b 100644 --- a/src/contracts/captures/captureToolContracts.ts +++ b/src/contracts/captures/captureToolContracts.ts @@ -15,7 +15,7 @@ export const CAPTURE_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_packet_fields"), kind: "artifact-provider", description: - "Discover protocols or field names/types registered in the installed TShark 4.x. kind defaults to protocols; use fields and optional exact protocol abbreviation to select field definitions. query searches name/abbreviation/description by case-insensitive literal substring. Return 50 matches by default with offset/limit paging; null requests all remaining within resource limits. Preserve type/base/bitmask/description, duplicate registrations, source line numbers, coverage, tool identity and diagnostics. Linux, REA_TSHARK_COMMAND and prlimit required. No capture file, interface or binary target needed. Registrations describe tool capabilities, not observed traffic. Each call rereads the glossary; use selected fields with inspect_packet_capture.", + "Discover protocols or field names/types registered in the installed TShark 4.x. kind defaults to protocols; use fields and optional exact protocol abbreviation to select field definitions. query searches name/abbreviation/description by case-insensitive literal substring. Return 50 matches by default with offset/limit paging; null requests all remaining within resource limits. Preserve type/base/bitmask/description, duplicate registrations, source line numbers, coverage, tool identity and diagnostics. Linux, TShark 4.x (PATH or absolute REA_TSHARK_COMMAND) and prlimit required. No capture file, interface or binary target needed. Registrations describe tool capabilities, not observed traffic. Each call rereads the glossary; use selected fields with inspect_packet_capture.", inputSchema: inspectPacketFieldsInputSchema, outputSchema: evidenceResultOf(packetFieldsCatalogSchema), examples: [ @@ -31,7 +31,7 @@ export const CAPTURE_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_packet_capture"), kind: "artifact-provider", description: - "Inspect caller-selected fields in an existing local packet capture with TShark 4.x. Return 50 matching frames by default, with match_offset/match_limit paging and coverage counts; match_limit:null requests all remaining matches. Preserve repeated values, frame numbers, display filter, decode-as rules and pass mode in inline Evidence with source/tool identity and diagnostics. Optional packet_limit counts input packets before filtering; output paging does not shorten dissection or two-pass reassembly. Requires Linux, util-linux prlimit, and absolute REA_TSHARK_COMMAND. File-only analysis; no live capture or device communication. Missing fields or no matches do not establish absence of device traffic.", + "Inspect caller-selected fields in an existing local packet capture with TShark 4.x. Return 50 matching frames by default, with match_offset/match_limit paging and coverage counts; match_limit:null requests all remaining matches. Preserve repeated values, frame numbers, display filter, decode-as rules and pass mode in inline Evidence with source/tool identity and diagnostics. Optional packet_limit counts input packets before filtering; output paging does not shorten dissection or two-pass reassembly. Requires Linux, util-linux prlimit, and TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND. File-only analysis; no live capture or device communication. Missing fields or no matches do not establish absence of device traffic.", inputSchema: inspectPacketCaptureInputSchema, outputSchema: evidenceResultOf(packetCaptureSchema), examples: [ diff --git a/src/domain/analysisView/pcapCaptureView.test.ts b/src/domain/analysisView/pcapCaptureView.test.ts index 6e56ea3e60..48603df3b4 100644 --- a/src/domain/analysisView/pcapCaptureView.test.ts +++ b/src/domain/analysisView/pcapCaptureView.test.ts @@ -74,6 +74,23 @@ const diagnostics = (offset: number, limit: number) => limit, }) as const; +it("reads historical capture Evidence without inventing executable identity", () => { + const result = projectAnalysisView(capture(""), { kind: "summary" }); + if (!result.ok) throw result.error; + if (result.value.kind !== "summary") + throw new Error("Expected a packet summary."); + if (!("client" in result.value.summary)) + throw new Error("Expected packet client facts."); + expect(result.value.summary.client).toMatchObject({ + tshark_resolved_path: null, + tshark_sha256: null, + tshark_identity_status: "unresolved", + capinfos_resolved_path: null, + capinfos_sha256: null, + capinfos_identity_status: "unresolved", + }); +}); + it("pages diagnostics without splitting a surrogate pair", () => { const parent = capture("ab😀cd"); expect(projectAnalysisView(parent, diagnostics(3, 2))).toMatchObject({ diff --git a/src/domain/analysisView/pcapCaptureView.ts b/src/domain/analysisView/pcapCaptureView.ts index 7aee9a40bc..e246869aad 100644 --- a/src/domain/analysisView/pcapCaptureView.ts +++ b/src/domain/analysisView/pcapCaptureView.ts @@ -1,6 +1,6 @@ import { analysisViewInputError } from "./analysisViewErrors.js"; import { z } from "zod"; -import { pcapResultSchemas } from "../pcap/pcapAnalysis.js"; +import { pcapEvidenceSchemas } from "../pcap/pcapAnalysis.js"; import { parseAnalysisViewResult } from "./analysisViewParent.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import { err, ok, type Result } from "../result.js"; @@ -15,7 +15,7 @@ import { } from "./analysisViewCoverage.js"; import { analysisTextPage } from "./analysisViewText.js"; -const captureSchema = pcapResultSchemas.inspect_pcap_capture; +const captureSchema = pcapEvidenceSchemas.inspect_pcap_capture; const fields = captureSchema.shape; const pageShape = { kind: z.literal("pcap"), diff --git a/src/domain/captures/packetCapture.ts b/src/domain/captures/packetCapture.ts index 2ff565b9f3..6f1bbabd54 100644 --- a/src/domain/captures/packetCapture.ts +++ b/src/domain/captures/packetCapture.ts @@ -130,6 +130,11 @@ export const packetCaptureSchema = z diagnostics: z.string(), tool: z.strictObject({ configured_path: z.string(), + selection_source: z + .enum(["environment", "path"]) + .describe( + "Whether REA_TSHARK_COMMAND selected the tool or PATH located it", + ), path: z.string(), sha256: z.string().regex(/^[a-f0-9]{64}$/), version_banner: z.string(), diff --git a/src/domain/pcap/pcapAnalysis.ts b/src/domain/pcap/pcapAnalysis.ts index 43c26feec4..09d6898164 100644 --- a/src/domain/pcap/pcapAnalysis.ts +++ b/src/domain/pcap/pcapAnalysis.ts @@ -41,9 +41,29 @@ const client = z.strictObject({ tshark_command: z.string().min(1), tshark_source: z.enum(["environment", "path"]), tshark_version: z.string().min(1).nullable(), + tshark_resolved_path: localPathStringSchema.nullable(), + tshark_sha256: digestSchema.nullable(), + tshark_identity_status: z.enum(["verified", "unresolved"]), capinfos_command: z.string().min(1), capinfos_source: z.enum(["environment", "path"]), capinfos_version: z.string().min(1).nullable(), + capinfos_resolved_path: localPathStringSchema.nullable(), + capinfos_sha256: digestSchema.nullable(), + capinfos_identity_status: z.enum(["verified", "unresolved"]), +}); + +/** Defaults for historical report Evidence written before executable identity was recorded. */ +const legacyClientIdentity = client.extend({ + tshark_resolved_path: localPathStringSchema.nullable().default(null), + tshark_sha256: digestSchema.nullable().default(null), + tshark_identity_status: z + .enum(["verified", "unresolved"]) + .default("unresolved"), + capinfos_resolved_path: localPathStringSchema.nullable().default(null), + capinfos_sha256: digestSchema.nullable().default(null), + capinfos_identity_status: z + .enum(["verified", "unresolved"]) + .default("unresolved"), }); const endpoint = z.strictObject({ @@ -108,3 +128,10 @@ export const pcapResultSchemas = { .optional(), }), } as const; + +/** Read compatibility for retained capture Evidence from earlier REA versions. */ +export const pcapEvidenceSchemas = { + inspect_pcap_capture: pcapResultSchemas.inspect_pcap_capture.extend({ + client: legacyClientIdentity, + }), +} as const; diff --git a/src/pcap/PcapConfiguration.test.ts b/src/pcap/PcapConfiguration.test.ts index 5fa458893a..0a76107d43 100644 --- a/src/pcap/PcapConfiguration.test.ts +++ b/src/pcap/PcapConfiguration.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from "vitest"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { join, relative } from "node:path"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { CAPINFOS_COMMAND_ENV, TSHARK_COMMAND_ENV, + locateWiresharkTool, + parseWiresharkVersion, resolvePcapTools, + selectWiresharkTool, + wiresharkExecutableIdentity, } from "./PcapConfiguration.js"; import { parseCapinfosOutput, parseTsharkJsonOutput } from "./PcapOutput.js"; @@ -20,13 +28,51 @@ describe("resolvePcapTools", () => { }); }); - it("falls back to PATH resolution without the environment keys", () => { - expect(resolvePcapTools({})).toEqual({ + it("keeps bare PATH selections for the Windows PATH/PATHEXT launcher", () => { + expect(resolvePcapTools({}, "win32")).toEqual({ tshark: { command: "tshark", source: "path" }, capinfos: { command: "capinfos", source: "path" }, }); }); + it("fails closed when no absolute PATH entry holds the tool", () => { + expect(() => resolvePcapTools({ PATH: "relative-only" }, "linux")).toThrow( + /tshark was not found in an absolute PATH entry/u, + ); + }); + + // Windows X_OK reports existence only, so executable-bit selection is POSIX-only. + it.skipIf(process.platform === "win32")( + "selects the same executable as the packet tools ahead of a relative PATH entry", + async () => { + const root = await createTestTempDirectory("rea-wireshark-shared-"); + const decoy = join(root, "relative-decoy"); + const installed = join(root, "installed"); + for (const directory of [decoy, installed]) { + await mkdir(directory); + for (const tool of ["tshark", "capinfos"]) { + await writeFile(join(directory, tool), ""); + await chmod(join(directory, tool), 0o700); + } + } + const environment = { + PATH: [relative(process.cwd(), decoy), installed].join(":"), + }; + + const pcap = resolvePcapTools(environment, "linux"); + expect(pcap).toEqual({ + tshark: { command: join(installed, "tshark"), source: "path" }, + capinfos: { command: join(installed, "capinfos"), source: "path" }, + }); + expect(pcap.tshark.command).toBe( + locateWiresharkTool( + selectWiresharkTool(environment, "tshark"), + environment, + ), + ); + }, + ); + it("rejects a relative tshark path explicitly", () => { expect(() => resolvePcapTools({ [TSHARK_COMMAND_ENV]: "bin/tshark" }), @@ -40,6 +86,89 @@ describe("resolvePcapTools", () => { }); }); +describe("locateWiresharkTool", () => { + it("keeps an environment selection as the configured absolute path", () => { + expect( + locateWiresharkTool( + { command: "/opt/wireshark/tshark", source: "environment" }, + { PATH: "/usr/bin" }, + ), + ).toBe("/opt/wireshark/tshark"); + }); + + // Windows X_OK reports existence only, so executable-bit selection is POSIX-only. + it.skipIf(process.platform === "win32")( + "uses the first absolute PATH entry holding an executable regular file", + async () => { + const root = await createTestTempDirectory("rea-wireshark-path-"); + // An absolute PATH entry that does not exist must be skipped. + const missingDirectory = join(root, "missing-directory"); + const notExecutable = join(root, "not-executable"); + const directoryEntry = join(root, "directory-entry"); + const selected = join(root, "selected"); + const later = join(root, "later"); + for (const directory of [notExecutable, directoryEntry, selected, later]) + await mkdir(directory); + await writeFile(join(notExecutable, "tshark"), ""); + await chmod(join(notExecutable, "tshark"), 0o600); + await mkdir(join(directoryEntry, "tshark")); + for (const directory of [selected, later]) { + await writeFile(join(directory, "tshark"), ""); + await chmod(join(directory, "tshark"), 0o700); + } + const PATH = [ + "relative-entry", + missingDirectory, + notExecutable, + directoryEntry, + selected, + later, + ].join(":"); + + expect( + locateWiresharkTool({ command: "tshark", source: "path" }, { PATH }), + ).toBe(join(selected, "tshark")); + expect( + locateWiresharkTool( + { command: "tshark", source: "path" }, + { PATH: [missingDirectory, notExecutable, directoryEntry].join(":") }, + ), + ).toBeUndefined(); + expect( + locateWiresharkTool({ command: "tshark", source: "path" }, {}), + ).toBeUndefined(); + }, + ); +}); + +describe("parseWiresharkVersion", () => { + it("admits Wireshark 4.x banners for either tool and rejects other majors", () => { + expect( + parseWiresharkVersion( + "\nTShark (Wireshark) 4.6.9 (Git v4.6.9 packaged as 4.6.9-1)\n", + "tshark", + ), + ).toBe("TShark (Wireshark) 4.6.9 (Git v4.6.9 packaged as 4.6.9-1)"); + expect( + parseWiresharkVersion("Capinfos (Wireshark) 4.0.17\n", "capinfos"), + ).toBe("Capinfos (Wireshark) 4.0.17"); + expect(parseWiresharkVersion("tcpdump 4.99.4\n", "tshark")).toBeNull(); + expect(() => + parseWiresharkVersion("TShark (Wireshark) 3.6.2\n", "tshark"), + ).toThrow(/requires Wireshark 4\.x/u); + }); +}); + +it("projects executable snapshots to compact identity facts", () => { + const bytes = new Uint8Array(64); + const identity = wiresharkExecutableIdentity({ + bytes, + sha256: "a".repeat(64), + }); + expect(identity).toEqual({ bytes: 64, sha256: "a".repeat(64) }); + expect(Object.values(identity)).not.toContain(bytes); +}); + const REAL_CAPINFOS = [ "File name: /work/rich-capture.pcap", "File type: Wireshark/tcpdump/... - pcap", diff --git a/src/pcap/PcapConfiguration.ts b/src/pcap/PcapConfiguration.ts index b2a6212d19..9e53ba879c 100644 --- a/src/pcap/PcapConfiguration.ts +++ b/src/pcap/PcapConfiguration.ts @@ -1,10 +1,43 @@ -import { isAbsolute } from "node:path"; +import { accessSync, constants, statSync } from "node:fs"; +import { delimiter, isAbsolute, join } 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"; +import { AnalysisArtifactChangedError } from "../domain/analysisErrorCore.js"; + +export interface WiresharkExecutableIdentity { + readonly bytes: number; + readonly sha256: string; +} + +/** Retain only identity facts after a bounded executable read. */ +export const wiresharkExecutableIdentity = (snapshot: { + readonly bytes: Uint8Array; + readonly sha256: string; +}): WiresharkExecutableIdentity => ({ + bytes: snapshot.bytes.byteLength, + sha256: snapshot.sha256, +}); + +/** Shared identity owner used to verify each caller-selected tool run. */ +export const assertWiresharkExecutableIdentity = ( + operation: string, + path: string, + expected: WiresharkExecutableIdentity, + actual: WiresharkExecutableIdentity, + options?: AnalysisErrorOptions, +): void => { + if (actual.bytes !== expected.bytes || actual.sha256 !== expected.sha256) + throw new AnalysisArtifactChangedError( + operation, + path, + "The selected Wireshark executable changed during analysis.", + options, + ); +}; /** Environment keys selecting the caller's tshark and capinfos binaries. */ export const TSHARK_COMMAND_ENV = "REA_TSHARK_COMMAND"; @@ -27,64 +60,99 @@ export const pcapToolUnavailable = ( 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" }; - readonly capinfos: { command: string; source: "environment" | "path" }; +/** How one caller-supplied Wireshark tool was selected. */ +export interface WiresharkToolSelection { + readonly command: string; + readonly source: "environment" | "path"; } +/** How each tool was selected for this provider instance. */ +export interface PcapToolSelection { + readonly tshark: WiresharkToolSelection; + readonly capinfos: WiresharkToolSelection; +} + +const TOOL_COMMAND_ENV = { + tshark: TSHARK_COMMAND_ENV, + capinfos: CAPINFOS_COMMAND_ENV, +} as const; + /** - * Resolve the caller-supplied tshark and capinfos binaries. Each accepts an - * absolute environment path or falls back to PATH resolution, mirroring the - * mitmdump pattern. REA never installs the Wireshark suite. + * The one selection rule for a caller-supplied Wireshark tool: an absolute + * environment path, otherwise the bare tool name resolved through PATH. A + * relative environment value is configuration to fix, never a PATH lookup. + * REA never installs the Wireshark suite. + */ +export const selectWiresharkTool = ( + environment: Readonly>, + tool: "tshark" | "capinfos", +): WiresharkToolSelection => { + const key = TOOL_COMMAND_ENV[tool]; + const configured = environment[key]; + if (configured === undefined || configured.trim() === "") + return { command: tool, source: "path" }; + if (!isAbsolute(configured)) + throw new PcapConfigurationFailure( + `${tool}_not_absolute`, + `${key} must be an absolute path to the ${tool} binary`, + { configured }, + ); + return { command: configured, source: "environment" }; +}; + +/** + * Resolve the caller-supplied tshark and capinfos binaries to the executables + * the packet tools would also select. Windows keeps PATH/PATHEXT resolution in + * the launcher, which the POSIX locator does not model. */ export const resolvePcapTools = ( environment: Readonly>, + platform: NodeJS.Platform = process.platform, ): PcapToolSelection => { - const tsharkEnv = environment[TSHARK_COMMAND_ENV]; - const capinfosEnv = environment[CAPINFOS_COMMAND_ENV]; - if ( - tsharkEnv !== undefined && - tsharkEnv.trim() !== "" && - !isAbsolute(tsharkEnv) - ) - throw new PcapConfigurationFailure( - "tshark_not_absolute", - `${TSHARK_COMMAND_ENV} must be an absolute path to the tshark binary`, - { configured: tsharkEnv }, - ); - if ( - capinfosEnv !== undefined && - capinfosEnv.trim() !== "" && - !isAbsolute(capinfosEnv) - ) - throw new PcapConfigurationFailure( - "capinfos_not_absolute", - `${CAPINFOS_COMMAND_ENV} must be an absolute path to the capinfos binary`, - { configured: capinfosEnv }, - ); - return { - tshark: { - command: - tsharkEnv === undefined || tsharkEnv.trim() === "" - ? "tshark" - : tsharkEnv, - source: - tsharkEnv === undefined || tsharkEnv.trim() === "" - ? "path" - : "environment", - }, - capinfos: { - command: - capinfosEnv === undefined || capinfosEnv.trim() === "" - ? "capinfos" - : capinfosEnv, - source: - capinfosEnv === undefined || capinfosEnv.trim() === "" - ? "path" - : "environment", - }, + // Report every configuration error before consulting PATH for either tool. + const selected = { + tshark: selectWiresharkTool(environment, "tshark"), + capinfos: selectWiresharkTool(environment, "capinfos"), }; + const locate = (tool: "tshark" | "capinfos"): WiresharkToolSelection => { + const selection = selected[tool]; + if (selection.source === "environment" || platform === "win32") + return selection; + const command = locateWiresharkTool(selection, environment); + if (command === undefined) + throw new PcapConfigurationFailure( + `${tool}_missing`, + `${tool} was not found in an absolute PATH entry; add it to PATH or set ${TOOL_COMMAND_ENV[tool]} to its absolute path`, + ); + return { command, source: "path" }; + }; + return { tshark: locate("tshark"), capinfos: locate("capinfos") }; +}; + +/** + * Locate a selected tool as an absolute executable so its identity can be + * hashed. Environment selections are already absolute. PATH selections use the + * first absolute PATH entry holding an executable regular file; relative PATH + * entries are skipped because they would depend on the working directory. + * Returns undefined when PATH holds no such executable. + */ +export const locateWiresharkTool = ( + selection: WiresharkToolSelection, + environment: Readonly>, +): string | undefined => { + if (selection.source === "environment" || isAbsolute(selection.command)) + return selection.command; + for (const directory of (environment.PATH ?? "").split(delimiter)) { + if (!isAbsolute(directory)) continue; + const candidate = join(directory, selection.command); + try { + accessSync(candidate, constants.X_OK); + if (statSync(candidate).isFile()) return candidate; + } catch { + // Absent or non-executable candidates leave later PATH entries eligible. + } + } + return undefined; }; export type PcapConfigurationFailureCode = @@ -108,8 +176,11 @@ export class PcapConfigurationFailure extends Error { } } -/** Recognize the selected Wireshark tool's version banner. */ -const parseVersion = ( +/** + * Recognize the selected Wireshark tool's version banner: null when the first + * line is not a Wireshark banner, a failure when its major version is not 4. + */ +export const parseWiresharkVersion = ( stdout: string, tool: "tshark" | "capinfos", ): string | null => { @@ -127,7 +198,7 @@ const parseVersion = ( if (parsed[1] !== "4") throw new PcapConfigurationFailure( `${tool}_version_unsupported`, - "Packet summary inspection currently requires Wireshark 4.x tools; select an installation with the admitted output profile.", + "Packet capture inspection currently requires Wireshark 4.x tools; select an installation with the admitted output profile.", { version: line }, ); return line; @@ -143,8 +214,11 @@ export const inspectPcapClient = async ( selection: PcapToolSelection, probe: (command: string) => Promise, ): Promise => ({ - tsharkVersion: parseVersion(await probe(selection.tshark.command), "tshark"), - capinfosVersion: parseVersion( + tsharkVersion: parseWiresharkVersion( + await probe(selection.tshark.command), + "tshark", + ), + capinfosVersion: parseWiresharkVersion( await probe(selection.capinfos.command), "capinfos", ), diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts index 304f99b77d..51555d736a 100644 --- a/src/pcap/PcapProvider.ts +++ b/src/pcap/PcapProvider.ts @@ -23,11 +23,15 @@ import { err, ok, type Result } from "../domain/result.js"; import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; import { PcapConfigurationFailure, + assertWiresharkExecutableIdentity, inspectPcapClient, + locateWiresharkTool, resolvePcapTools, pcapConfigurationAvailability, pcapToolUnavailable, + wiresharkExecutableIdentity, type PcapToolSelection, + type WiresharkExecutableIdentity, } from "./PcapConfiguration.js"; import { parseCapinfosOutput, @@ -46,8 +50,11 @@ import { createPcapEvidence } from "../domain/pcap/pcapEvidence.js"; import { preparePcapNativeBound } from "./PcapNativeBound.js"; import { PcapSnapshot } from "./PcapSnapshot.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.js"; import { ArtifactAnalysisResources } from "../process/ArtifactAnalysisResources.js"; import { readLinuxResourceLimits } from "../process/LinuxResourceLimits.js"; +import { realpath } from "node:fs/promises"; export const PCAP_PROVIDER_IDENTITY = { id: "pcap", @@ -95,6 +102,27 @@ const pcapPartialEvidence = ( /** Completed Pcap output whose only failure is its retained command cleanup. */ class PcapCommandCleanupError extends ProviderCleanupError {} +/** Report a probe whose owned command still needs cleanup after it returned. */ +const probeCleanupFailure = ( + operation: PcapOperation, + cleanup: PcapCommandCleanup, + output: { readonly stdout: string; readonly stderr: string }, +): ProviderCleanupError => + new ProviderCleanupError( + "pcap", + cleanup.resources, + { + reason: cleanup.reason, + previous_error: null, + captured_output: { + stdout: output.stdout, + stderr: output.stderr, + truncated: cleanup.capturedOutput.truncated, + }, + }, + { operation }, + ); + const pcapCommandCleanupError = ( operation: PcapOperation, snapshotPath: string | undefined, @@ -128,6 +156,7 @@ const pcapCommandCleanupError = ( export interface PcapProviderOptions { readonly environment?: Readonly>; readonly launcher?: PcapCommandLauncher; + readonly artifactReader?: typeof readStableArtifact; } interface RetainedPcapCommand { @@ -148,18 +177,13 @@ interface RetainedSnapshotFailure { /** Inspect saved captures through bounded, owned tool processes and one stable input copy. */ export class PcapProvider { - readonly #resources = new ArtifactAnalysisResources( - "pcap", - "inspect_pcap_capture", - readStableArtifact, - readLinuxResourceLimits, - { - message: "A packet summary is already active; retry after it completes.", - details: { max_concurrent_inspections: 1 }, - }, - ); + readonly #resources: ArtifactAnalysisResources; private readonly environment: Readonly>; private readonly launcher: PcapCommandLauncher | undefined; + private readonly wiresharkIdentities = new Map< + string, + { readonly path: string; readonly identity: WiresharkExecutableIdentity } + >(); private readonly snapshots = new Set(); private readonly snapshotFailures = new Map< PcapSnapshot, @@ -173,6 +197,17 @@ export class PcapProvider { constructor(options: PcapProviderOptions = {}) { this.environment = options.environment ?? process.env; this.launcher = options.launcher; + this.#resources = new ArtifactAnalysisResources( + "pcap", + "inspect_pcap_capture", + options.artifactReader ?? readStableArtifact, + readLinuxResourceLimits, + { + message: + "A packet summary is already active; retry after it completes.", + details: { max_concurrent_inspections: 1 }, + }, + ); } private async run( @@ -187,10 +222,25 @@ export class PcapProvider { let cleanup: PcapCommandCleanup | undefined; let retainedCommand: RetainedPcapCommand | undefined; let output: Awaited>; + let commandReturned = false; + const selected = this.wiresharkIdentities.get(command); try { + if (selected !== undefined) { + const before = await this.#resources.read( + selected.path, + 64 * 1024 * 1024, + signal, + ); + assertWiresharkExecutableIdentity( + operation, + selected.path, + selected.identity, + wiresharkExecutableIdentity(before), + ); + } output = await runPcapCommand({ operation, - command, + command: selected?.path ?? command, arguments: args, limits, signal, @@ -219,30 +269,58 @@ export class PcapProvider { cleanup = { reason, resources, capturedOutput }; }, }); + commandReturned = true; + if (selected !== undefined) { + const after = await this.#resources.read( + selected.path, + 64 * 1024 * 1024, + ); + assertWiresharkExecutableIdentity( + operation, + selected.path, + selected.identity, + wiresharkExecutableIdentity(after), + { + capturedOutput: { + stdout: output.stdout, + stderr: output.stderr, + truncated: output.cleanup?.capturedOutput.truncated ?? false, + }, + }, + ); + } } catch (cause) { if ( retainedCommand !== undefined && cause instanceof AnalysisError && cause.cleanupIncomplete - ) + ) { retainedCommand.failure = cause; + throw cause; + } + // A returned probe has no snapshot to carry its pending command cleanup, + // so a later failure (such as the post-run identity check) must carry it. + // Failures raised by the command itself keep their own cleanup outcome. + if (commandReturned && inputPath === undefined && cleanup !== undefined) { + const pending = probeCleanupFailure( + operation, + cleanup, + cleanup.capturedOutput, + ); + if (retainedCommand !== undefined) retainedCommand.failure = pending; + // The classified failure may own its own cleanup, such as an + // executable re-read whose file close failed; keep both live. + const primary = this.analysisFailure(cause, operation); + throw analysisErrorWithCleanupFailure( + primary, + pending, + operation, + ).bindCleanupDependencies([primary, pending]); + } throw cause; } if (inputPath === undefined && cleanup !== undefined) { - const failure = new ProviderCleanupError( - "pcap", - cleanup.resources, - { - reason: cleanup.reason, - previous_error: null, - captured_output: { - stdout: output.stdout, - stderr: output.stderr, - truncated: cleanup.capturedOutput.truncated, - }, - }, - { operation }, - ); + const failure = probeCleanupFailure(operation, cleanup, output); if (retainedCommand !== undefined) retainedCommand.failure = failure; throw failure; } @@ -263,6 +341,40 @@ export class PcapProvider { private async client(operation: PcapOperation, signal?: AbortSignal) { const selection = resolvePcapTools(this.environment); + this.wiresharkIdentities.clear(); + for (const [name, selected] of [ + ["tshark", selection.tshark], + ["capinfos", selection.capinfos], + ] as const) { + const path = locateWiresharkTool(selected, this.environment); + if (path === undefined) continue; + let resolvedPath: string; + try { + resolvedPath = await realpath(path); + } catch (cause) { + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "ENOTDIR") + ) + throw new PcapConfigurationFailure( + `${name}_missing`, + `The selected ${name} executable ${selected.command} could not be found at its configured path.`, + { command: selected.command }, + { cause }, + ); + throw cause; + } + const snapshot = await this.#resources.read( + resolvedPath, + 64 * 1024 * 1024, + signal, + ); + this.wiresharkIdentities.set(selected.command, { + path: resolvedPath, + identity: wiresharkExecutableIdentity(snapshot), + }); + } const identity = await inspectPcapClient( selection, async (command) => @@ -292,9 +404,30 @@ export class PcapProvider { tshark_command: selection.tshark.command, tshark_source: selection.tshark.source, tshark_version: identity.tsharkVersion, + tshark_resolved_path: + this.wiresharkIdentities.get(selection.tshark.command)?.path ?? null, + tshark_sha256: + this.wiresharkIdentities.get(selection.tshark.command)?.identity + .sha256 ?? null, + tshark_identity_status: this.wiresharkIdentities.has( + selection.tshark.command, + ) + ? "verified" + : "unresolved", capinfos_command: selection.capinfos.command, capinfos_source: selection.capinfos.source, capinfos_version: identity.capinfosVersion, + capinfos_resolved_path: + this.wiresharkIdentities.get(selection.capinfos.command)?.path ?? + null, + capinfos_sha256: + this.wiresharkIdentities.get(selection.capinfos.command)?.identity + .sha256 ?? null, + capinfos_identity_status: this.wiresharkIdentities.has( + selection.capinfos.command, + ) + ? "verified" + : "unresolved", }, }; } @@ -333,7 +466,7 @@ export class PcapProvider { }; } catch (cause) { if ( - cause instanceof ProviderCleanupError || + (cause instanceof AnalysisError && cause.cleanupIncomplete) || cause instanceof AnalysisCancelledError ) throw cause; @@ -509,6 +642,45 @@ export class PcapProvider { }, options?.signal); } + /** Classify a request failure the same way for every pcap operation. */ + private analysisFailure( + cause: unknown, + operation: PcapOperation, + ): AnalysisError { + const classifyArtifactFailure = (failure: unknown): AnalysisError => + failure instanceof AnalysisError + ? failure + : failure instanceof ArtifactReaderFailure && + failure.reason === "cancelled" + ? new AnalysisCancelledError(operation, { + cause: artifactFileCancellationCause(failure), + ...(failure.partialObservation === undefined + ? {} + : { partialObservation: failure.partialObservation }), + }) + : new AnalysisProtocolError( + `Packet-capture analysis failed: ${failure instanceof Error ? failure.message : String(failure)}`, + { cause: failure }, + ); + return cause instanceof ArtifactReaderFailure + ? this.#resources.cleanupFailure( + cause, + classifyArtifactFailure, + undefined, + operation, + ) + : cause instanceof AnalysisError + ? cause + : cause instanceof PcapConfigurationFailure + ? pcapToolUnavailable(operation, cause.message, { + cause, + }) + : new AnalysisProtocolError( + `Packet-capture analysis failed: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ); + } + private async executeAdmitted( request: PcapRequest, options?: ExecutionOptions, @@ -567,18 +739,7 @@ export class PcapProvider { ); } } catch (cause) { - result = err( - cause instanceof AnalysisError - ? cause - : cause instanceof PcapConfigurationFailure - ? pcapToolUnavailable(request.operation, cause.message, { - cause, - }) - : new AnalysisProtocolError( - `Packet-capture analysis failed: ${cause instanceof Error ? cause.message : String(cause)}`, - { cause }, - ), - ); + result = err(this.analysisFailure(cause, request.operation)); } if (signal?.aborted && result.ok) result = err( diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 88e1cba7d8..0e1c0a141d 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -140,6 +140,10 @@ import type { AndroidAnalysisPort } from "../application/android/AndroidAnalysis import type { ApktoolResourceAnalysisPort } from "../application/apktool/ApktoolResourceAnalysisPort.js"; import type { PcapAnalysisPort } from "../application/pcap/PcapAnalysisPort.js"; import { PcapAnalysisService } from "../application/pcap/PcapAnalysisService.js"; +import { + locateWiresharkTool, + selectWiresharkTool, +} from "../pcap/PcapConfiguration.js"; import { ApktoolResourceAnalysisService } from "../application/apktool/ApktoolResourceAnalysisService.js"; import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBuildAnalysisPort.js"; import { FlutterBuildAnalysisService } from "../application/flutter/FlutterBuildAnalysisService.js"; @@ -231,6 +235,21 @@ export interface CreateServerOptions { readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures; } +/** Apply the shared TShark selection rule without starting the tool. */ +const tsharkLocatable = (environment: Readonly): boolean => { + try { + return ( + locateWiresharkTool( + selectWiresharkTool(environment, "tshark"), + environment, + ) !== undefined + ); + } catch { + // A relative REA_TSHARK_COMMAND is configuration the tool reports at execution. + return false; + } +}; + const installSessionToolAvailability = ( session: BinarySessionPort | undefined, options: CreateServerOptions, @@ -247,7 +266,7 @@ const installSessionToolAvailability = ( optionalFeatures: { packetCaptureEnabled: options.packetCapture !== undefined || - (linux && isAbsolute(environment.REA_TSHARK_COMMAND ?? "")), + (linux && tsharkLocatable(environment)), signalRecordingEnabled: options.signalRecording !== undefined || (linux && isAbsolute(environment.REA_SIGROK_COMMAND ?? "")), diff --git a/tests/boundary/captures/captureProvider.test.ts b/tests/boundary/captures/captureProvider.test.ts index b4540b1797..e8182c7b07 100644 --- a/tests/boundary/captures/captureProvider.test.ts +++ b/tests/boundary/captures/captureProvider.test.ts @@ -26,7 +26,7 @@ const fixture = async (mode = "ok", failCleanup = false) => { await writeFile( script, `import {readFileSync, writeFileSync, chmodSync, truncateSync} from 'node:fs'; -if (process.argv.includes('--version')) { console.log(${JSON.stringify(mode)} === 'bad-version' ? 'TShark (Wireshark) 3.0.0' : 'TShark (Wireshark) 4.6.9 (fixture)'); console.error('version warning'); } +if (process.argv.includes('--version')) { console.log(${JSON.stringify(mode)} === 'bad-version' ? 'TShark (Wireshark) 3.0.0' : ${JSON.stringify(mode)} === 'foreign-version' ? 'tcpdump version 4.99.4' : 'TShark (Wireshark) 4.6.9 (fixture)'); console.error('version warning'); } else if (${JSON.stringify(mode)} === 'nonzero') { console.error('invalid decoder'); process.exitCode = 1; } else if (${JSON.stringify(mode)} === 'malformed-copy') { chmodSync('capture', 0o600); writeFileSync('capture', 'changed'); } else if (${JSON.stringify(mode)} === 'oversized-copy') { chmodSync('capture', 0o600); truncateSync('capture', 32 * 1024 * 1024 + 1); console.log('[]'); } @@ -42,6 +42,7 @@ else { if(readFileSync('capture', 'utf8') !== 'recording bytes') throw Error('wr const configuredCommand = mode === "symlink" ? join(directory, "configured-tool") : script; if (mode === "symlink") await symlink(script, configuredCommand); + if (mode === "path") await symlink(script, join(directory, "tshark")); const controller = new AbortController(); let calls = 0; let mayClean = !failCleanup; @@ -50,7 +51,9 @@ else { if(readFileSync('capture', 'utf8') !== 'recording bytes') throw Error('wr const provider = new TsharkCaptureProvider( { ...process.env, - REA_TSHARK_COMMAND: configuredCommand, + ...(mode === "path" + ? { PATH: directory, REA_TSHARK_COMMAND: undefined } + : { REA_TSHARK_COMMAND: configuredCommand }), REA_TSHARK_PRLIMIT_COMMAND: configuredCommand, }, async (input) => { @@ -409,6 +412,67 @@ it("refuses unsupported hosts before launching or allocating a workspace", async }); }); +it("selects TShark from PATH with the shared rule and records the selection", async () => { + const f = await fixture("path"); + const result = await f.service.inspect({ path: f.path, fields: ["ip.addr"] }); + if (!result.ok) throw result.error; + expect(result.value.normalized_result).toMatchObject({ + tool: { + configured_path: join(f.directory, "tshark"), + selection_source: "path", + path: join(f.directory, "producer.mjs"), + }, + }); +}); + +it.each([ + [ + "bad-version", + "Packet capture inspection currently requires Wireshark 4.x tools", + ], + [ + "foreign-version", + "Expected TShark 4.x; inspect the selected installation.", + ], +] as const)( + "refuses an unadmitted version banner before dissection: %s", + async (mode, reason) => { + const f = await fixture(mode); + const result = await f.service.inspect({ + path: f.path, + fields: ["ip.addr"], + }); + if (result.ok) throw new Error("Expected version refusal"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + reason: expect.stringContaining(reason), + }); + expect(f.calls()).toBe(1); + }, +); + +it("refuses a relative REA_TSHARK_COMMAND instead of searching PATH", async () => { + const provider = new TsharkCaptureProvider( + { REA_TSHARK_COMMAND: "tshark", PATH: "/usr/bin" }, + async () => { + throw new Error("Must not launch"); + }, + async () => { + throw new Error("Must not allocate"); + }, + "linux", + ); + const result = await new PacketCaptureService(provider).inspect({ + path: "/recording.pcap", + fields: ["ip.addr"], + }); + if (result.ok) throw new Error("Expected configuration refusal"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + reason: "REA_TSHARK_COMMAND must be an absolute path to the tshark binary", + }); +}); + it("preserves complete Evidence when aborted during post-run integrity checks", async () => { const f = await fixture("post-run-abort"); const result = await f.service.inspect( @@ -436,6 +500,7 @@ it("preserves configured symlink paths separately from resolved tool identities" expect(result.value.normalized_result).toMatchObject({ tool: { configured_path: join(f.directory, "configured-tool"), + selection_source: "environment", path: join(f.directory, "producer.mjs"), }, resource_limiter: { diff --git a/tests/boundary/cli/pcapCli.test.ts b/tests/boundary/cli/pcapCli.test.ts index 1eb61cdcff..aa4986bd3b 100644 --- a/tests/boundary/cli/pcapCli.test.ts +++ b/tests/boundary/cli/pcapCli.test.ts @@ -21,7 +21,7 @@ it.each([ }); expectConfigurationRefusal( result, - "Cannot run selected tool /nonexistent/tshark", + "The selected tshark executable /nonexistent/tshark could not be found", ); expectConfigurationRefusal(result, "through absolute REA_TSHARK_COMMAND"); }); diff --git a/tests/boundary/mcp/packetCaptureAvailability.test.ts b/tests/boundary/mcp/packetCaptureAvailability.test.ts new file mode 100644 index 0000000000..9bd9c26c57 --- /dev/null +++ b/tests/boundary/mcp/packetCaptureAvailability.test.ts @@ -0,0 +1,104 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { + expandToolAvailability, + sessionProvider, +} from "../../../src/contracts/toolOutputSchemaPrimitives.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +// The server enables default packet tools only on Linux, where they can run. +const linux = process.platform === "linux"; + +const packetCaptureAvailability = async ( + environment: Readonly>, +): Promise | undefined> => { + const session = createTestBinarySession(() => { + throw new Error("binary analysis must not start during status discovery"); + }); + const server = createServer( + { kind: "session", session }, + { environment: { ...environment, REA_MCP_OUTPUT_SCHEMA_PROFILE: "off" } }, + ); + const client = new Client({ + name: "packet-capture-availability", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + const response = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + if (response.isError === true) throw new Error("binary_session failed"); + const parsed = z + .object({ result: sessionProvider }) + .parse(response.structuredContent).result; + return expandToolAvailability(parsed.tool_availability).find( + ({ name }) => name === "inspect_packet_capture", + ); + } finally { + await Promise.allSettled([client.close(), server.close(), session.close()]); + } +}; + +const executableTshark = async (prefix: string): Promise => { + const directory = await createTestTempDirectory(prefix); + await mkdir(join(directory, "bin")); + const tshark = join(directory, "bin", "tshark"); + await writeFile(tshark, ""); + await chmod(tshark, 0o700); + return join(directory, "bin"); +}; + +it.runIf(linux)( + "reports packet tools available for a PATH-only TShark install", + async () => { + const bin = await executableTshark("rea-packet-path-only-"); + expect(await packetCaptureAvailability({ PATH: bin })).toMatchObject({ + available: true, + reason: "available", + }); + }, +); + +it.runIf(linux)( + "reports packet tools available for an absolute REA_TSHARK_COMMAND", + async () => { + const bin = await executableTshark("rea-packet-absolute-"); + expect( + await packetCaptureAvailability({ + PATH: "", + REA_TSHARK_COMMAND: join(bin, "tshark"), + }), + ).toMatchObject({ available: true, reason: "available" }); + }, +); + +it.runIf(linux)( + "reports packet tools missing for a relative REA_TSHARK_COMMAND even when PATH holds TShark", + async () => { + const bin = await executableTshark("rea-packet-relative-"); + expect( + await packetCaptureAvailability({ + PATH: bin, + REA_TSHARK_COMMAND: "bin/tshark", + }), + ).toMatchObject({ available: false, reason: "provider_missing" }); + }, +); + +it("reports packet tools missing when no absolute PATH entry holds TShark", async () => { + expect( + await packetCaptureAvailability({ PATH: "relative-entry" }), + ).toMatchObject({ available: false, reason: "provider_missing" }); +}); diff --git a/tests/boundary/mcp/pcapMcp.test.ts b/tests/boundary/mcp/pcapMcp.test.ts index dff164ee55..95154eb14e 100644 --- a/tests/boundary/mcp/pcapMcp.test.ts +++ b/tests/boundary/mcp/pcapMcp.test.ts @@ -15,9 +15,15 @@ const clientIdentity = { tshark_command: "/usr/bin/tshark", tshark_source: "path", tshark_version: "TShark (Wireshark) 4.6.6", + tshark_resolved_path: null, + tshark_sha256: null, + tshark_identity_status: "unresolved", capinfos_command: "/usr/bin/capinfos", capinfos_source: "path", capinfos_version: "Capinfos (Wireshark) 4.6.6", + capinfos_resolved_path: null, + capinfos_sha256: null, + capinfos_identity_status: "unresolved", }; it("advertises exact valid pcap schemas and records inline evidence through MCP", async () => { diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts index fbc8b13584..798ca65094 100644 --- a/tests/boundary/pcap/pcapProvider.test.ts +++ b/tests/boundary/pcap/pcapProvider.test.ts @@ -141,7 +141,7 @@ const captureFixture = async (root: string): Promise => { }; it("inspects both tools without reading a capture", async () => { - const { provider } = await providerWith(); + const { provider, root } = await providerWith(); const result = await evidenceResult(provider, { operation: "inspect_pcap_client", input: {}, @@ -149,11 +149,50 @@ it("inspects both tools without reading a capture", async () => { expect(result.client).toMatchObject({ tshark_source: "environment", tshark_version: "TShark (Wireshark) 4.6.6-stub", + tshark_sha256: createHash("sha256") + .update(await readFile(join(root, "tshark"))) + .digest("hex"), + tshark_identity_status: "verified", capinfos_source: "environment", capinfos_version: "Capinfos (Wireshark) 4.6.6-stub", + capinfos_sha256: createHash("sha256") + .update(await readFile(join(root, "capinfos"))) + .digest("hex"), + capinfos_identity_status: "verified", }); }); +it("rejects a selected TShark whose bytes change during its version probe", async () => { + const root = await createTestTempDirectory("rea-pcap-tool-identity-"); + const { tshark, capinfos } = await writeStubTools(root); + await writeFile( + tshark, + `#!/bin/sh +if [ "$1" = "--version" ]; then + printf 'TShark (Wireshark) 4.6.6-stub\\n' + printf '\\n# changed during version probe\\n' >> "$0" + exit 0 +fi +exit 2 +`, + { mode: 0o755 }, + ); + const provider = new PcapProvider({ + environment: { REA_TSHARK_COMMAND: tshark, REA_CAPINFOS_COMMAND: capinfos }, + }); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "inspect_pcap_client", + input: {}, + }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "artifact_changed", + details: { path: tshark }, + }); +}); + it("inspects a capture with endpoints, DNS, TLS SNI, and HTTP", async () => { const { provider, root } = await providerWith(); const path = await captureFixture(root); diff --git a/tests/boundary/pcap/pcapProviderCancellation.test.ts b/tests/boundary/pcap/pcapProviderCancellation.test.ts index 95b3312204..a3a2818055 100644 --- a/tests/boundary/pcap/pcapProviderCancellation.test.ts +++ b/tests/boundary/pcap/pcapProviderCancellation.test.ts @@ -1,8 +1,14 @@ -import { writeFile } from "node:fs/promises"; +import { realpath, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { expect, onTestFinished, test, vi } from "vitest"; -import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { + AnalysisArtifactChangedError, + AnalysisCancelledError, +} from "../../../src/domain/analysisErrorCore.js"; +import { AnalysisCleanupLease } from "../../../src/artifacts/AnalysisCleanupLease.js"; +import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; +import { readStableArtifact } from "../../../src/artifacts/readStableArtifact.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { resolvedAnalysisError } from "../../../src/domain/analysisErrorBase.js"; import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; @@ -62,6 +68,7 @@ const fixture = async ( reason: unknown, failedCleanupAttempts: number, activeVersion = false, + artifactReader: typeof readStableArtifact = readStableArtifact, ) => { const root = await createTestTempDirectory("rea-pcap-provider-cancel-"); const tools = await writeTools(root, mode, activeVersion); @@ -100,11 +107,111 @@ const fixture = async ( REA_CAPINFOS_COMMAND: tools.capinfos, }, launcher, + artifactReader, }); onTestFinished(async () => provider.close()); return { provider, root, capture, cleanupAttempts: () => cleanupAttempts }; }; +// The tshark version probe's owned process cleanup fails once, and the tool's +// bytes appear changed when the probe re-reads it after the run. +const probeCleanupFixture = async (rereadCloseFailure = false) => { + const root = await createTestTempDirectory("rea-pcap-probe-cleanup-"); + const tools = await writeTools(root, "complete"); + const tshark = await realpath(tools.tshark); + let tsharkReads = 0; + let probeCleanupAttempts = 0; + const launcher: PcapCommandLauncher = async (spawn) => { + const launched = await spawnOwnedProviderProcess(spawn); + if (!spawn.arguments.includes("--version")) return launched; + const cleanup = + launched.cleanup ?? (() => cleanupOwnedProcessGroup(launched.ownership)); + return { + ...launched, + cleanup: async () => { + probeCleanupAttempts += 1; + if (probeCleanupAttempts === 1) + throw new Error("injected probe cleanup failure"); + return cleanup(); + }, + }; + }; + const artifactReader: typeof readStableArtifact = async (...args) => { + const snapshot = await readStableArtifact(...args); + if (args[0] !== tshark) return snapshot; + tsharkReads += 1; + // Reads: identity capture, pre-run check, post-run check. + if (tsharkReads !== 3) return snapshot; + if (!rereadCloseFailure) return { ...snapshot, sha256: "0".repeat(64) }; + const resource = `executable-read:${tshark}`; + throw new ArtifactReaderFailure("io", "injected executable read failure", { + cleanup: { reason: "injected file close failure", resources: [resource] }, + cleanupLeases: [new AnalysisCleanupLease(async () => {}, [resource])], + }); + }; + const provider = new PcapProvider({ + environment: { + REA_TSHARK_COMMAND: tools.tshark, + REA_CAPINFOS_COMMAND: tools.capinfos, + }, + launcher, + artifactReader, + }); + onTestFinished(async () => provider.close()); + return provider; +}; + +it("reports a probe's pending cleanup when its post-run identity check fails", async () => { + const provider = await probeCleanupFixture(); + const result = await provider.execute({ + operation: "inspect_pcap_client", + input: {}, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.cleanupIncomplete).toBe(true); + expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete"); + expect(result.error.cleanupPrimary).toBeInstanceOf( + AnalysisArtifactChangedError, + ); + expect( + result.error.cleanupResources.some((path) => + path.startsWith("process-group:"), + ), + ).toBe(true); +}); + +it("keeps an executable re-read's own cleanup live beside the probe's pending cleanup", async () => { + const provider = await probeCleanupFixture(true); + const result = await provider.execute({ + operation: "inspect_pcap_client", + input: {}, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^executable-read:/u), + expect.stringMatching(/^process-group:/u), + ]), + ); +}); + +it("availability surfaces a probe's pending cleanup instead of reporting the tool unavailable", async () => { + const provider = await probeCleanupFixture(); + let failure: unknown; + try { + await provider.inspectAvailability(); + } catch (cause) { + failure = cause; + } + expect(failure).toMatchObject({ cleanupIncomplete: true }); + expect( + (failure as { cleanupPrimary?: unknown }).cleanupPrimary, + ).toBeInstanceOf(AnalysisArtifactChangedError); +}); + it("availability preserves the producer's exact cancellation reason", async () => { const controller = new AbortController(); const reason = new ProviderCleanupError( @@ -123,6 +230,31 @@ it("availability preserves the producer's exact cancellation reason", async () = expect(failure).toMatchObject({ cause: reason }); }); +it("executable identity hashing preserves artifact-read cancellation", async () => { + const controller = new AbortController(); + const reason = { caller: "cancel executable identity read" }; + const { provider } = await fixture( + "active", + controller, + reason, + 0, + false, + async () => { + throw new ArtifactReaderFailure("cancelled", "identity read cancelled", { + cancellation: { reason }, + }); + }, + ); + const result = await provider.execute( + { operation: "inspect_pcap_client", input: {} }, + { signal: controller.signal }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); +}); + it("active cancellation stays primary through retained command and snapshot cleanup retry", async () => { const controller = new AbortController(); const reason = "caller cancellation token"; diff --git a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts index 8170e712a6..6f1cde0b6e 100644 --- a/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts +++ b/tests/boundary/pcap/pcapProviderCrossPlatform.test.ts @@ -7,7 +7,7 @@ * refusal paths, and cancellation before spawn. */ import { createHash } from "node:crypto"; -import { open, writeFile } from "node:fs/promises"; +import { open, readFile, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, onTestFinished, vi } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -52,6 +52,41 @@ const evidenceResult = async ( return result.value.result as Record; }; +// Windows keeps PATH/PATHEXT resolution in the launcher; POSIX hosts locate the file. +it.skipIf(isWindows)( + "runs and reports the tools located on PATH rather than bare names", + async () => { + const directory = await createTestTempDirectory("rea-pcap-path-"); + await symlink(tsharkStub, join(directory, "tshark")); + await symlink(capinfosStub, join(directory, "capinfos")); + const provider = new PcapProvider({ + environment: { PATH: `relative-entry:${directory}` }, + }); + onTestFinished(() => provider.close()); + const result = await evidenceResult(provider, { + operation: "inspect_pcap_client", + input: {}, + }); + expect(result.client).toMatchObject({ + tshark_command: join(directory, "tshark"), + tshark_source: "path", + tshark_version: expect.stringContaining("4.6.6") as unknown, + tshark_resolved_path: await realpath(tsharkStub), + tshark_sha256: createHash("sha256") + .update(await readFile(join(directory, "tshark"))) + .digest("hex"), + tshark_identity_status: "verified", + capinfos_command: join(directory, "capinfos"), + capinfos_source: "path", + capinfos_resolved_path: await realpath(capinfosStub), + capinfos_sha256: createHash("sha256") + .update(await readFile(join(directory, "capinfos"))) + .digest("hex"), + capinfos_identity_status: "verified", + }); + }, +); + it("inspects both tools through cross-platform stubs", async () => { const result = await evidenceResult(providerOver(), { operation: "inspect_pcap_client", @@ -60,11 +95,81 @@ it("inspects both tools through cross-platform stubs", async () => { expect(result.client).toMatchObject({ tshark_source: "environment", tshark_version: expect.stringContaining("4.6.6") as unknown, + tshark_resolved_path: await realpath(tsharkStub), capinfos_source: "environment", capinfos_version: expect.stringContaining("4.6.6") as unknown, + capinfos_resolved_path: await realpath(capinfosStub), + tshark_sha256: createHash("sha256") + .update(await readFile(tsharkStub)) + .digest("hex"), + tshark_identity_status: "verified", + capinfos_sha256: createHash("sha256") + .update(await readFile(capinfosStub)) + .digest("hex"), + capinfos_identity_status: "verified", }); }); +it("projects a missing configured tool as a capability gap", async () => { + const provider = new PcapProvider({ + environment: { + REA_TSHARK_COMMAND: tsharkStub, + REA_CAPINFOS_COMMAND: join(import.meta.dirname, "missing-capinfos"), + }, + }); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "inspect_pcap_client", + input: {}, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "capability_unavailable", + message: expect.stringContaining( + "Supply Wireshark 4.x tshark and capinfos", + ), + }); +}); + +it.skipIf(isWindows)( + "rejects TShark that changes during its version probe", + async () => { + const directory = await createTestTempDirectory("rea-pcap-mutated-tool-"); + const tshark = join(directory, "tshark"); + const capinfos = join(directory, "capinfos"); + await writeFile( + tshark, + `#!/bin/sh +if [ "$1" = "--version" ]; then + printf 'TShark (Wireshark) 4.6.6-stub\\n' + printf '\\n# changed during version probe\\n' >> "$0" + exit 0 +fi +exit 2 +`, + { mode: 0o755 }, + ); + await symlink(capinfosStub, capinfos); + const provider = new PcapProvider({ + environment: { + REA_TSHARK_COMMAND: tshark, + REA_CAPINFOS_COMMAND: capinfos, + }, + }); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "inspect_pcap_client", + input: {}, + }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "artifact_changed", + }); + }, +); + it("resolves a retained tool-probe error after its process owner closes", async () => { let mayClean = false; let launches = 0;