diff --git a/src/pcap/PcapProvider.ts b/src/pcap/PcapProvider.ts index 2b10f068c2..3f1d7d235e 100644 --- a/src/pcap/PcapProvider.ts +++ b/src/pcap/PcapProvider.ts @@ -1007,6 +1007,13 @@ export class PcapProvider { if (metadata.sha256 !== null && metadata.sha256 !== identity.sha256) throw new AnalysisProtocolError( "capinfos SHA256 disagrees with the owned capture snapshot", + { + diagnostics: { + reason: "capture_sha256_mismatch", + capinfos_sha256: metadata.sha256, + snapshot_sha256: identity.sha256, + }, + }, ); } catch (cause) { throw withOutputCleanup(cause, info); @@ -1119,6 +1126,13 @@ export class PcapProvider { if (fields.packetCount !== metadata.packetCount) throw new AnalysisProtocolError( `capinfos counted ${String(metadata.packetCount)} packets but tshark returned ${String(fields.packetCount)}`, + { + diagnostics: { + reason: "packet_count_mismatch", + capinfos_packet_count: metadata.packetCount, + tshark_packet_count: fields.packetCount, + }, + }, ); const captured = { result: { diff --git a/tests/acceptance/pcapOutputUtf8CliMcp.test.ts b/tests/acceptance/pcapOutputUtf8CliMcp.test.ts index 65b3e91500..5c16585544 100644 --- a/tests/acceptance/pcapOutputUtf8CliMcp.test.ts +++ b/tests/acceptance/pcapOutputUtf8CliMcp.test.ts @@ -1,4 +1,6 @@ import { expect } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { toolContract } from "../../src/contracts/toolContracts.js"; import { parseEvidence } from "../../src/domain/evidence.js"; @@ -47,6 +49,46 @@ const environmentFor = ( REA_CAPINFOS_COMMAND: tools.capinfos, }); +cliTest.skipIf(process.platform !== "linux")( + "preserves contradictory packet counts through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-pcap-count-mismatch-"); + const path = join(root, "capture.pcap"); + await writeFile(path, Buffer.from("pcap-fixture-bytes")); + const { tshark, capinfos } = await writeStubTools(root, { + tsharkJson: "[]", + capinfosText: + "File type: pcap\nFile encapsulation: Ethernet\nNumber of packets: 1\n", + }); + const environment = environmentFor(root, { tshark, capinfos }); + const response = await cli.run({ + arguments: ["inspect-pcap-capture", path, "--json"], + environment, + }); + expect(response.exitCode, response.stdout).toBe(1); + expect(response.json).toMatchObject({ + code: "unreadable_output", + details: { + diagnostics: { + reason: "packet_count_mismatch", + capinfos_packet_count: 1, + tshark_packet_count: 0, + }, + }, + }); + const { client, call } = await connectLocalToolsMcp(environment); + try { + const reply = await call("inspect_pcap_capture", { path }); + expect(reply.isError).toBe(true); + const text = reply.content.find((item) => item.type === "text"); + if (text?.type !== "text") throw new Error("Missing MCP error text"); + expect(JSON.parse(text.text)).toEqual({ error: response.json }); + } finally { + await client.close(); + } + }, +); + cliTest.skipIf(process.platform === "win32")( "returns the same unreadable-output diagnostic for invalid version streams through CLI and stdio MCP", async ({ cli }) => { diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts index 026db53e01..c67506a3f0 100644 --- a/tests/boundary/pcap/pcapProvider.test.ts +++ b/tests/boundary/pcap/pcapProvider.test.ts @@ -158,6 +158,66 @@ it("inspects a capture with endpoints, DNS, TLS SNI, and HTTP", async () => { expect(result.coverage).toBe("complete"); }); +it.each([0, 1])( + "preserves packet-count mismatch facts while accepting an empty capture: capinfos=%s", + async (count) => { + const { provider, root } = await providerWith({ + tsharkJson: "[]", + dnsJson: "[]", + capinfosText: `File type: pcap\nFile encapsulation: Ethernet\nNumber of packets: ${String(count)}\n`, + }); + const path = await captureFixture(root); + const result = await provider.execute({ + operation: "inspect_pcap_capture", + input: { path }, + }); + if (count === 0) { + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + packet_count: 0, + coverage: "complete", + }); + } else { + if (result.ok) throw new Error("Expected contradictory counts"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unreadable_output", + details: { + diagnostics: { + reason: "packet_count_mismatch", + capinfos_packet_count: 1, + tshark_packet_count: 0, + }, + }, + }); + } + }, +); + +it("retains both observed digests when capinfos disagrees with the snapshot", async () => { + const reported = "0".repeat(64); + const { provider, root } = await providerWith({ + capinfosText: `File type: pcap\nFile encapsulation: Ethernet\nNumber of packets: 0\nSHA256: ${reported}\n`, + }); + const path = await captureFixture(root); + const result = await provider.execute({ + operation: "inspect_pcap_capture", + input: { path }, + }); + if (result.ok) throw new Error("Expected contradictory snapshot digest"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unreadable_output", + details: { + diagnostics: { + reason: "capture_sha256_mismatch", + capinfos_sha256: reported, + snapshot_sha256: createHash("sha256") + .update("pcap-fixture-bytes") + .digest("hex"), + }, + }, + }); +}); + it("retains an absolute HTTP request target without a Host-derived full URI", async () => { const { provider, root } = await providerWith({ httpAbsoluteUriOnly: true }); const path = await captureFixture(root); diff --git a/tests/fixtures/pcap/pcapTestTools.ts b/tests/fixtures/pcap/pcapTestTools.ts index ca6688204f..a4a67c54ef 100644 --- a/tests/fixtures/pcap/pcapTestTools.ts +++ b/tests/fixtures/pcap/pcapTestTools.ts @@ -93,6 +93,7 @@ export const TSHARK_DNS_JSON = JSON.stringify([ export interface PcapStubToolsOptions { readonly failCapinfos?: boolean; readonly malformedDnsJson?: boolean; + readonly dnsJson?: string; readonly tsharkJson?: string; readonly capinfosText?: string; readonly httpAbsoluteUriOnly?: boolean; @@ -142,7 +143,7 @@ case " $* " in case " $* " in *" --no-duplicate-keys "*) ;; *) exit 2 ;; esac case " $* " in *" -J frame dns mdns llmnr "*) ;; *) exit 2 ;; esac case " $* " in *" -Y dns || mdns || llmnr "*) ;; *) exit 2 ;; esac - printf '%s' '${(options.malformedDnsJson ? "{" : TSHARK_DNS_JSON).replace(/'/gu, "'\\''")}' + printf '%s' '${(options.malformedDnsJson ? "{" : (options.dnsJson ?? TSHARK_DNS_JSON)).replace(/'/gu, "'\\''")}' exit 0 ;; *)