Files
rea/src/pcap/PcapConfiguration.test.ts
T
Aaditya 92a9591a41 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 <noreply@anthropic.com>

* 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 <path>" 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 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-23 02:34:14 +08:00

379 lines
13 KiB
TypeScript

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";
describe("resolvePcapTools", () => {
it("prefers absolute environment paths for both tools", () => {
expect(
resolvePcapTools({
[TSHARK_COMMAND_ENV]: "/usr/bin/tshark",
[CAPINFOS_COMMAND_ENV]: "/usr/bin/capinfos",
}),
).toEqual({
tshark: { command: "/usr/bin/tshark", source: "environment" },
capinfos: { command: "/usr/bin/capinfos", source: "environment" },
});
});
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" }),
).toThrow(/must be an absolute path/u);
});
it("rejects a relative capinfos path explicitly", () => {
expect(() =>
resolvePcapTools({ [CAPINFOS_COMMAND_ENV]: "capinfos" }),
).toThrow(/must be an absolute path/u);
});
});
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",
"File encapsulation: Linux cooked-mode capture v2",
"Packet size limit: file hdr: 262144 bytes",
"Number of packets: 50",
"File size: 23279 bytes",
"Capture duration: 0.373473 seconds",
"Earliest packet time: 1791728020.608505",
"Latest packet time: 1791728020.981978",
"SHA256: c94f94742b3288b9a979711509ed5eab3028576301f8938319de3c22a6f5c40b",
].join("\n");
describe("parseCapinfosOutput", () => {
it("projects metadata from real capinfos output", () => {
expect(parseCapinfosOutput(REAL_CAPINFOS)).toEqual({
fileType: "Wireshark/tcpdump/... - pcap",
encapsulation: "Linux cooked-mode capture v2",
packetCount: 50,
captureStart: "2026-10-11T14:13:40.608505Z",
captureEnd: "2026-10-11T14:13:40.981978Z",
captureDurationSeconds: 0.373473,
snapshotLength: 262144,
sha256:
"c94f94742b3288b9a979711509ed5eab3028576301f8938319de3c22a6f5c40b",
});
});
it("retains timestamps from the older capinfos labels", () => {
const output = REAL_CAPINFOS.replace(
"Earliest packet time",
"First packet time",
).replace("Latest packet time", "Last packet time");
const metadata = parseCapinfosOutput(output);
expect(metadata.captureStart).toBe("2026-10-11T14:13:40.608505Z");
expect(metadata.captureEnd).toBe("2026-10-11T14:13:40.981978Z");
});
it("projects epoch timestamps to UTC instants and keeps other spellings unknown", () => {
const withTimes = (start: string, end: string): string =>
REAL_CAPINFOS.replace("1791728020.608505", start).replace(
"1791728020.981978",
end,
);
expect(
parseCapinfosOutput(withTimes("1791728020", "1791728020.500000")),
).toMatchObject({
captureStart: "2026-10-11T14:13:40Z",
captureEnd: "2026-10-11T14:13:40.500000Z",
});
// capinfos prints n/a for an empty capture, and its default wall-clock
// spelling carries no zone, so neither names an instant.
expect(
parseCapinfosOutput(withTimes("n/a", "2026-10-11 14:13:40.981978")),
).toMatchObject({ captureStart: null, captureEnd: null });
// a packet before the epoch prints with a leading minus; the whole seconds
// and the fraction are borrowed so the instant stays exact
expect(parseCapinfosOutput(withTimes("-1", "-1.250000"))).toMatchObject({
captureStart: "1969-12-31T23:59:59Z",
captureEnd: "1969-12-31T23:59:58.750000Z",
});
expect(parseCapinfosOutput(withTimes("-0.5", "0"))).toMatchObject({
captureStart: "1969-12-31T23:59:59.5Z",
captureEnd: "1970-01-01T00:00:00Z",
});
// beyond the RFC 3339 year range in either direction stays unknown
expect(
parseCapinfosOutput(withTimes("-62167219201", "253402300800")),
).toMatchObject({ captureStart: null, captureEnd: null });
});
it.each([
["0.000000001", "1970-01-01T00:00:00.000000001Z"],
["-0.000000001", "1969-12-31T23:59:59.999999999Z"],
["-1.000000", "1969-12-31T23:59:59.000000Z"],
["-0.000000", "1970-01-01T00:00:00.000000Z"],
["-62167219200.000000001", null],
["-62167219200", "0000-01-01T00:00:00Z"],
["253402300799.999999999", "9999-12-31T23:59:59.999999999Z"],
["253402300800", null],
["NaN", null],
["Infinity", null],
["1791728020.5junk", null],
["1e3", null],
["1.1234567890", null],
["9".repeat(1000), null],
])("preserves the exact instant or unknown for %s", (text, expected) => {
const metadata = parseCapinfosOutput(
REAL_CAPINFOS.replace("1791728020.608505", text).replace(
"1791728020.981978",
text,
),
);
expect(metadata.captureStart).toBe(expected);
expect(metadata.captureEnd).toBe(expected);
});
it("keeps missing capture timestamps unknown", () => {
const output = REAL_CAPINFOS.split("\n")
.filter((line) => !line.includes("packet time:"))
.join("\n");
expect(parseCapinfosOutput(output)).toMatchObject({
captureStart: null,
captureEnd: null,
});
});
it("rejects empty metadata rather than reporting complete unknown facts", () => {
expect(() => parseCapinfosOutput("")).toThrow(/machine-readable/u);
});
});
const REAL_TSHARK_JSON = JSON.stringify([
{
_source: {
layers: {
"frame.number": ["1"],
"frame.protocols": ["sll:ethertype:ip:udp:dns"],
"ip.src": ["192.168.1.1"],
"ip.dst": ["8.8.8.8"],
"dns.qry.name": ["example.com"],
"dns.flags.response": ["1"],
"dns.a": ["93.184.216.34"],
},
},
},
{
_source: {
layers: {
"frame.number": ["2"],
"frame.protocols": ["sll:ethertype:ip:tcp:tls"],
"ip.src": ["192.168.1.1"],
"ip.dst": ["93.184.216.34"],
"tls.handshake.extensions_server_name": ["example.com"],
},
},
},
{
_source: {
layers: {
"frame.number": ["3"],
"frame.protocols": ["sll:ethertype:ip:tcp:http"],
"ip.src": ["192.168.1.1"],
"ip.dst": ["93.184.216.34"],
"http.request.full_uri": ["http://example.com/page"],
},
},
},
]);
describe("parseTsharkJsonOutput", () => {
it("projects endpoints, DNS, TLS SNI, protocols, and HTTP URIs", async () => {
const result = await parseTsharkJsonOutput(REAL_TSHARK_JSON);
expect(result.endpoints).toEqual(
expect.arrayContaining([
{ address: "192.168.1.1", port: null, packet_count: 3 },
{ address: "8.8.8.8", port: null, packet_count: 1 },
{ address: "93.184.216.34", port: null, packet_count: 2 },
]),
);
expect(result.dnsQueries).toEqual([
{
query: "example.com",
response_addresses: ["93.184.216.34"],
response_address_count: 1,
},
]);
expect(result.tlsSniValues).toEqual(["example.com"]);
expect(result.httpRequestUris).toEqual(["http://example.com/page"]);
expect(result.protocolDistribution).toEqual(
expect.arrayContaining([
{ protocol: "sll:ethertype:ip:udp:dns", packet_count: 1 },
{ protocol: "sll:ethertype:ip:tcp:tls", packet_count: 1 },
{ protocol: "sll:ethertype:ip:tcp:http", packet_count: 1 },
]),
);
});
it("retains every endpoint within the aggregate resource budget", async () => {
const packets = Array.from({ length: 10 }, (_, index) => ({
_source: {
layers: {
"frame.number": [String(index + 1)],
"frame.protocols": ["ip:tcp"],
"ip.src": [`10.0.0.${index}`],
"ip.dst": ["10.0.1.1"],
},
},
}));
const result = await parseTsharkJsonOutput(JSON.stringify(packets));
expect(result.endpoints).toHaveLength(11);
expect(result.endpointCount).toBe(11); // 10 src + 1 dst
});
it.each([
"",
"not json",
"{}",
"null",
"[null]",
'[{"_source":{"layers":{"ip.src":[3]}}}]',
])("rejects malformed producer output %s", async (text) => {
await expect(parseTsharkJsonOutput(text)).rejects.toMatchObject({
_tag: "AnalysisProtocolError",
});
});
});