Compare commits

...
10 Commits
Author SHA1 Message Date
morluto f2cffbdfdc fix(pcap): preserve executable read failures 2026-10-22 06:09:50 +08:00
morluto 5e046bb374 fix(pcap): read historical capture evidence 2026-10-22 06:05:55 +08:00
morluto 4924706e25 fix(pcap): report missing selected tools clearly 2026-10-22 05:52:17 +08:00
morluto 211d4b2219 perf(pcap): retain compact executable identity 2026-10-22 05:47:11 +08:00
morluto cb6dc4f3ef fix(pcap): share selected tool identity verification 2026-10-22 05:40:52 +08:00
Kaoru0822andClaude Opus 5.5 658c29b7fc 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 <noreply@anthropic.com>
2026-10-21 03:22:28 +00:00
Kaoru0822andClaude Opus 5.5 a22adc7809 Merge origin/main into refactor/tshark-single-owner
Resolve the node:fs/promises import conflict in pcapProviderCrossPlatform.test.ts by importing open, symlink and writeFile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 03:04:35 +00:00
555aaditya e74e4d8f0b Merge remote-tracking branch 'origin/main' into refactor/tshark-single-owner 2026-10-14 16:28:29 +05:30
555aaditya f4eeba3654 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
2026-10-14 16:28:29 +05:30
555aaditya bbecf6fb05 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
2026-10-14 15:05:58 +05:30
18 changed files with 733 additions and 96 deletions

No files matched your search

+9 -2
View File
@@ -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
+10 -1
View File
@@ -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.
+1 -1
View File
@@ -478,7 +478,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
+23 -14
View File
@@ -57,6 +57,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";
const OPERATION = "inspect_packet_capture";
export const TSHARK_PROVIDER_IDENTITY = {
@@ -139,18 +147,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))
@@ -220,11 +232,11 @@ export class TsharkCaptureProvider implements PacketCapturePort {
"Configured resource limiter changed during analysis.",
{ capturedOutput },
);
if (current.sha256 !== tool.sha256)
throw new AnalysisArtifactChangedError(
assertWiresharkExecutableIdentity(
operation,
command,
"Configured TShark executable changed during analysis.",
tool,
wiresharkExecutableIdentity(current),
{ capturedOutput },
);
phase = previousPhase;
@@ -245,11 +257,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.",
);
@@ -257,6 +265,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,
@@ -553,7 +562,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 (
@@ -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: [
@@ -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({
+2 -2
View File
@@ -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 { analysisInputErrorFromIssues } from "../inputIssueProjection.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"),
+5
View File
@@ -123,6 +123,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(),
+27
View File
@@ -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;
+130 -2
View File
@@ -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,88 @@ 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-");
const relative = join(root, "relative");
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",
relative,
notExecutable,
directoryEntry,
selected,
later,
].join(":");
expect(
locateWiresharkTool({ command: "tshark", source: "path" }, { PATH }),
).toBe(join(selected, "tshark"));
expect(
locateWiresharkTool(
{ command: "tshark", source: "path" },
{ PATH: [relative, 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",
+130 -56
View File
@@ -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<Record<string, string | undefined>>,
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<Record<string, string | undefined>>,
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<Record<string, string | undefined>>,
): 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<string>,
): Promise<PcapClientIdentity> => ({
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",
),
+136 -12
View File
@@ -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",
@@ -128,6 +135,7 @@ const pcapCommandCleanupError = (
export interface PcapProviderOptions {
readonly environment?: Readonly<Record<string, string | undefined>>;
readonly launcher?: PcapCommandLauncher;
readonly artifactReader?: typeof readStableArtifact;
}
interface RetainedPcapCommand {
@@ -148,18 +156,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<Record<string, string | undefined>>;
private readonly launcher: PcapCommandLauncher | undefined;
private readonly wiresharkIdentities = new Map<
string,
{ readonly path: string; readonly identity: WiresharkExecutableIdentity }
>();
private readonly snapshots = new Set<PcapSnapshot>();
private readonly snapshotFailures = new Map<
PcapSnapshot,
@@ -173,6 +176,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 +201,24 @@ export class PcapProvider {
let cleanup: PcapCommandCleanup | undefined;
let retainedCommand: RetainedPcapCommand | undefined;
let output: Awaited<ReturnType<typeof runPcapCommand>>;
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,6 +247,25 @@ export class PcapProvider {
cleanup = { reason, resources, capturedOutput };
},
});
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 &&
@@ -263,6 +310,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 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 +373,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",
},
};
}
@@ -567,8 +669,30 @@ export class PcapProvider {
);
}
} catch (cause) {
const classifyArtifactFailure = (failure: unknown): AnalysisError =>
failure instanceof AnalysisError
? failure
: failure instanceof ArtifactReaderFailure &&
failure.reason === "cancelled"
? new AnalysisCancelledError(request.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 },
);
result = err(
cause instanceof AnalysisError
cause instanceof ArtifactReaderFailure
? this.#resources.cleanupFailure(
cause,
classifyArtifactFailure,
undefined,
request.operation,
)
: cause instanceof AnalysisError
? cause
: cause instanceof PcapConfigurationFailure
? pcapToolUnavailable(request.operation, cause.message, {
+20 -1
View File
@@ -114,6 +114,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";
@@ -198,6 +202,21 @@ export interface CreateServerOptions {
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
}
/** Apply the shared TShark selection rule without starting the tool. */
const tsharkLocatable = (environment: Readonly<NodeJS.ProcessEnv>): 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,
@@ -214,7 +233,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 ?? "")),
@@ -41,6 +41,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;
@@ -49,7 +50,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) => {
@@ -408,6 +411,41 @@ 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("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(
@@ -435,6 +473,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: {
+6
View File
@@ -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 () => {
+40 -1
View File
@@ -141,7 +141,7 @@ const captureFixture = async (root: string): Promise<string> => {
};
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,8 +149,47 @@ 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 },
});
});
@@ -3,6 +3,8 @@ import { dirname, join } from "node:path";
import { expect, onTestFinished, test, vi } from "vitest";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.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 +64,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,6 +103,7 @@ const fixture = async (
REA_CAPINFOS_COMMAND: tools.capinfos,
},
launcher,
artifactReader,
});
onTestFinished(async () => provider.close());
return { provider, root, capture, cleanupAttempts: () => cleanupAttempts };
@@ -123,6 +127,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";
@@ -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<string, unknown>;
};
// 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;