From 1cd88d6800e6dfab7424917ad94b30bcfa8d49de Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 22:12:19 -0500 Subject: [PATCH 1/7] docs: add test coverage design spec and implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For issue #13 — 7 new reassembly engine integration tests covering SYN+ACK bidirectional data, flow eviction, FIN teardown, anomaly findings, and max_segments limit. --- .../2026-04-06-reassembly-test-coverage.md | 480 ++++++++++++++++++ ...6-04-06-reassembly-test-coverage-design.md | 142 ++++++ 2 files changed, 622 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-06-reassembly-test-coverage.md create mode 100644 docs/superpowers/specs/2026-04-06-reassembly-test-coverage-design.md diff --git a/docs/superpowers/plans/2026-04-06-reassembly-test-coverage.md b/docs/superpowers/plans/2026-04-06-reassembly-test-coverage.md new file mode 100644 index 0000000..0c02712 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-reassembly-test-coverage.md @@ -0,0 +1,480 @@ +# Reassembly Test Coverage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add 7 missing integration tests to the TCP reassembly engine, covering SYN+ACK bidirectional data, flow eviction, FIN teardown, anomaly findings, and segment limits. + +**Architecture:** All tests go in `tests/reassembly_engine_tests.rs`. The shared `make_tcp_packet` helper gains an `ack` parameter. No production code changes. + +**Tech Stack:** Rust 2024 edition, `cargo test` + +--- + +### Task 1: Add `ack` Parameter to `make_tcp_packet` Helper + +**Files:** +- Modify: `tests/reassembly_engine_tests.rs` + +- [ ] **Step 1: Update `make_tcp_packet` signature and body** + +Add `ack: bool` parameter after `syn`. Pass it to `TransportInfo::Tcp`: + +```rust +#[allow(clippy::too_many_arguments)] +fn make_tcp_packet( + src_ip: [u8; 4], + src_port: u16, + dst_ip: [u8; 4], + dst_port: u16, + seq: u32, + payload: &[u8], + syn: bool, + ack: bool, + fin: bool, + rst: bool, +) -> ParsedPacket { + ParsedPacket { + src_ip: IpAddr::V4(Ipv4Addr::from(src_ip)), + dst_ip: IpAddr::V4(Ipv4Addr::from(dst_ip)), + protocol: Protocol::Tcp, + transport: TransportInfo::Tcp { + src_port, + dst_port, + seq_number: seq, + syn, + ack, + fin, + rst, + }, + payload: payload.to_vec(), + packet_len: 54 + payload.len(), + } +} +``` + +- [ ] **Step 2: Update all existing call sites** + +Every existing call passes `false` for the new `ack` parameter (inserted after `syn`). The calls follow the pattern: + +```rust +// Before: +make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false) +// syn fin rst + +// After: +make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false, false) +// syn ack fin rst +``` + +Update ALL call sites in these tests: +- `test_three_packet_stream_ordered` (4 calls) +- `test_out_of_order_delivery` (4 calls) +- `test_mid_stream_no_syn` (1 call) +- `test_rst_closes_flow` (3 calls) +- `test_finalize_flushes_remaining` (2 calls) +- `test_flow_timeout_expiration` (1 call) +- `test_total_memory_tracking` (3 calls) +- `test_fin_close_total_memory` (4 calls) + +- [ ] **Step 3: Run tests to verify no regressions** + +Run: `cargo test --test reassembly_engine_tests` +Expected: All 8 existing tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add tests/reassembly_engine_tests.rs +git commit -m "refactor: add ack parameter to make_tcp_packet test helper" +``` + +--- + +### Task 2: Add SYN+ACK Bidirectional and FIN Teardown Tests + +**Files:** +- Modify: `tests/reassembly_engine_tests.rs` + +**Context:** These tests need the `Direction` import for asserting data direction. The `RecordingHandler` already records `Direction` in `data_events`. + +- [ ] **Step 1: Write `test_syn_ack_bidirectional_data`** + +```rust +#[test] +fn test_syn_ack_bidirectional_data() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // 3-way handshake: SYN, SYN+ACK + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + let syn_ack = make_tcp_packet(server, 80, client, 12345, 2000, &[], true, true, false, false); + reassembler.process_packet(&syn_ack, 2, &mut handler); + + // Client sends data + let req = make_tcp_packet(client, 12345, server, 80, 1001, b"request", false, false, false, false); + reassembler.process_packet(&req, 3, &mut handler); + + // Server sends data + let resp = make_tcp_packet(server, 80, client, 12345, 2001, b"response", false, false, false, false); + reassembler.process_packet(&resp, 4, &mut handler); + + // Verify proper handshake (not partial/mid-stream) + let stats = reassembler.stats(); + assert_eq!(stats.flows_partial, 0); + assert_eq!(stats.flows_total, 1); + + // Verify bidirectional data with correct directions + assert_eq!(handler.data_events.len(), 2); + assert_eq!(handler.data_events[0].1, Direction::ClientToServer); + assert_eq!(handler.data_events[0].2, b"request"); + assert_eq!(handler.data_events[1].1, Direction::ServerToClient); + assert_eq!(handler.data_events[1].2, b"response"); +} +``` + +- [ ] **Step 2: Write `test_full_handshake_fin_teardown`** + +```rust +#[test] +fn test_full_handshake_fin_teardown() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // Full 3-way handshake + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + let syn_ack = make_tcp_packet(server, 80, client, 12345, 2000, &[], true, true, false, false); + reassembler.process_packet(&syn_ack, 2, &mut handler); + + // Bidirectional data + let req = make_tcp_packet(client, 12345, server, 80, 1001, b"hello", false, false, false, false); + reassembler.process_packet(&req, 3, &mut handler); + + let resp = make_tcp_packet(server, 80, client, 12345, 2001, b"world", false, false, false, false); + reassembler.process_packet(&resp, 4, &mut handler); + + // FIN from client + let fin1 = make_tcp_packet(client, 12345, server, 80, 1006, &[], false, false, true, false); + reassembler.process_packet(&fin1, 5, &mut handler); + + // FIN from server + let fin2 = make_tcp_packet(server, 80, client, 12345, 2006, &[], false, false, true, false); + reassembler.process_packet(&fin2, 6, &mut handler); + + // Flow closed via FIN + let stats = reassembler.stats(); + assert_eq!(stats.flows_fin, 1); + assert_eq!(reassembler.total_memory(), 0); + + // Close reason is Fin + assert_eq!(handler.close_events.len(), 1); + assert_eq!(handler.close_events[0].1, CloseReason::Fin); + + // Both directions' data delivered + let client_data: Vec<&[u8]> = handler + .data_events + .iter() + .filter(|(_, d, _, _)| *d == Direction::ClientToServer) + .map(|(_, _, data, _)| data.as_slice()) + .collect(); + let server_data: Vec<&[u8]> = handler + .data_events + .iter() + .filter(|(_, d, _, _)| *d == Direction::ServerToClient) + .map(|(_, _, data, _)| data.as_slice()) + .collect(); + assert_eq!(client_data, vec![b"hello".as_slice()]); + assert_eq!(server_data, vec![b"world".as_slice()]); +} +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test --test reassembly_engine_tests` +Expected: All tests pass including 2 new ones. + +- [ ] **Step 4: Commit** + +```bash +git add tests/reassembly_engine_tests.rs +git commit -m "test: add SYN+ACK bidirectional and FIN teardown tests" +``` + +--- + +### Task 3: Add Eviction Tests (max_flows and memcap) + +**Files:** +- Modify: `tests/reassembly_engine_tests.rs` + +- [ ] **Step 1: Write `test_max_flows_eviction`** + +```rust +#[test] +fn test_max_flows_eviction() { + let config = ReassemblyConfig { + max_flows: 2, + ..ReassemblyConfig::default() + }; + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let server = [10, 0, 0, 2]; + + // Flow A (oldest): SYN + data + let syn_a = make_tcp_packet([10, 0, 0, 1], 1000, server, 80, 1000, &[], true, false, false, false); + reassembler.process_packet(&syn_a, 1, &mut handler); + let data_a = make_tcp_packet([10, 0, 0, 1], 1000, server, 80, 1001, b"aaa", false, false, false, false); + reassembler.process_packet(&data_a, 2, &mut handler); + + // Flow B: SYN + data + let syn_b = make_tcp_packet([10, 0, 0, 1], 2000, server, 80, 1000, &[], true, false, false, false); + reassembler.process_packet(&syn_b, 3, &mut handler); + let data_b = make_tcp_packet([10, 0, 0, 1], 2000, server, 80, 1001, b"bbb", false, false, false, false); + reassembler.process_packet(&data_b, 4, &mut handler); + + // Flow C: SYN triggers eviction (max_flows=2, already have 2) + let syn_c = make_tcp_packet([10, 0, 0, 1], 3000, server, 80, 1000, &[], true, false, false, false); + reassembler.process_packet(&syn_c, 5, &mut handler); + + // Eviction occurred + let stats = reassembler.stats(); + assert!(stats.evictions >= 1); + + // MemoryPressure close reason present + assert!( + handler + .close_events + .iter() + .any(|(_, r)| *r == CloseReason::MemoryPressure) + ); + + // Flow C was successfully created + assert_eq!(stats.flows_total, 3); +} +``` + +- [ ] **Step 2: Write `test_memcap_eviction`** + +```rust +#[test] +fn test_memcap_eviction() { + let config = ReassemblyConfig { + memcap: 10, + ..ReassemblyConfig::default() + }; + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // Flow A: SYN + out-of-order data (buffered, not flushed) + let syn_a = make_tcp_packet(client, 1000, server, 80, 1000, &[], true, false, false, false); + reassembler.process_packet(&syn_a, 1, &mut handler); + + // Skip offset 1 to prevent flush — send at offset 2 (seq 1002) + let data_a1 = make_tcp_packet(client, 1000, server, 80, 1002, b"aaaaa", false, false, false, false); + reassembler.process_packet(&data_a1, 2, &mut handler); + assert_eq!(reassembler.total_memory(), 5); + + // Flow B: SYN + out-of-order data that pushes past memcap + let syn_b = make_tcp_packet(client, 2000, server, 80, 2000, &[], true, false, false, false); + reassembler.process_packet(&syn_b, 3, &mut handler); + + let data_b1 = make_tcp_packet(client, 2000, server, 80, 2002, b"bbbbbb", false, false, false, false); + reassembler.process_packet(&data_b1, 4, &mut handler); + // total_memory would be 11 (5+6) which exceeds memcap=10, triggering eviction + + // Eviction should have fired + let stats = reassembler.stats(); + assert!(stats.evictions >= 1); + assert!(reassembler.total_memory() <= config.memcap); +} +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test --test reassembly_engine_tests` +Expected: All tests pass including 2 new ones. + +- [ ] **Step 4: Commit** + +```bash +git add tests/reassembly_engine_tests.rs +git commit -m "test: add max_flows and memcap eviction tests" +``` + +--- + +### Task 4: Add Anomaly Finding and Max Segments Tests + +**Files:** +- Modify: `tests/reassembly_engine_tests.rs` + +**Context:** `findings()` returns `&[Finding]`. The `Finding` struct has `summary: String`, `category: ThreatCategory`, `confidence: Confidence`. These are in `wirerust::findings`. + +- [ ] **Step 1: Add findings imports** + +At the top of the file, add: +```rust +use wirerust::findings::{Confidence, ThreatCategory}; +``` + +- [ ] **Step 2: Write `test_overlap_anomaly_finding`** + +Need 52 packets total: 1 SYN + 1 original + 50 duplicates = overlap_count 50 (not > 50). Need 51 duplicates = overlap_count 51 > 50. So 53 packets: 1 SYN + 1 original + 51 duplicates. + +```rust +#[test] +fn test_overlap_anomaly_finding() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // SYN + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + // Original segment + let original = make_tcp_packet(client, 12345, server, 80, 1001, b"AAAA", false, false, false, false); + reassembler.process_packet(&original, 2, &mut handler); + + // No findings yet + assert!(reassembler.findings().is_empty()); + + // Send 51 duplicates to reach overlap_count=51 (> threshold of 50) + for i in 0..51u32 { + let dup = make_tcp_packet(client, 12345, server, 80, 1001, b"AAAA", false, false, false, false); + reassembler.process_packet(&dup, 3 + i, &mut handler); + } + + // Overlap anomaly finding should be generated + let findings = reassembler.findings(); + assert!(!findings.is_empty(), "expected overlap anomaly finding"); + let overlap_finding = findings + .iter() + .find(|f| f.summary.contains("Excessive segment overlaps")) + .expect("overlap anomaly finding not found"); + assert_eq!(overlap_finding.category, ThreatCategory::Anomaly); +} +``` + +- [ ] **Step 3: Write `test_conflicting_overlap_finding`** + +```rust +#[test] +fn test_conflicting_overlap_finding() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // SYN + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + // Original segment + let original = make_tcp_packet(client, 12345, server, 80, 1001, b"AAAA", false, false, false, false); + reassembler.process_packet(&original, 2, &mut handler); + + // Conflicting retransmission: same offset, different data + let conflict = make_tcp_packet(client, 12345, server, 80, 1001, b"BBBB", false, false, false, false); + reassembler.process_packet(&conflict, 3, &mut handler); + + // Conflicting overlap finding should be generated + let findings = reassembler.findings(); + let conflict_finding = findings + .iter() + .find(|f| f.summary.contains("Conflicting TCP segment overlap")) + .expect("conflicting overlap finding not found"); + assert_eq!(conflict_finding.category, ThreatCategory::Anomaly); + assert_eq!(conflict_finding.confidence, Confidence::High); +} +``` + +- [ ] **Step 4: Write `test_max_segments_per_direction`** + +```rust +#[test] +fn test_max_segments_per_direction() { + let config = ReassemblyConfig { + max_segments_per_direction: 5, + ..ReassemblyConfig::default() + }; + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // SYN + let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false, false); + reassembler.process_packet(&syn, 1, &mut handler); + + // 5 non-contiguous segments (skip offset 1 to prevent flush) + // Offsets: 2, 4, 6, 8, 10 — each 1 byte with gaps between + for i in 0..5u32 { + let seq = 1002 + (i * 2); + let pkt = make_tcp_packet(client, 12345, server, 80, seq, b"x", false, false, false, false); + reassembler.process_packet(&pkt, 2 + i, &mut handler); + } + + let stats_before = reassembler.stats().segments_inserted; + + // 6th non-contiguous segment — should be rejected (DepthExceeded) + let rejected = make_tcp_packet(client, 12345, server, 80, 1012, b"y", false, false, false, false); + reassembler.process_packet(&rejected, 7, &mut handler); + + // segments_inserted should not have increased + assert_eq!(reassembler.stats().segments_inserted, stats_before); + + // Fill the gap at offset 1 — triggers flush of contiguous segments + let fill = make_tcp_packet(client, 12345, server, 80, 1001, b"Z", false, false, false, false); + reassembler.process_packet(&fill, 8, &mut handler); + + // Existing segments should flush intact (offset 1 "Z" + offset 2 "x") + assert!( + handler + .data_events + .iter() + .any(|(_, _, data, _)| data == b"Z"), + "gap-fill segment should flush" + ); + assert!( + handler + .data_events + .iter() + .any(|(_, _, data, _)| data == b"x"), + "existing buffered segment should flush intact" + ); +} +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test --test reassembly_engine_tests` +Expected: All tests pass including 3 new ones. + +- [ ] **Step 6: Commit** + +```bash +git add tests/reassembly_engine_tests.rs +git commit -m "test: add anomaly finding and max_segments tests" +``` diff --git a/docs/superpowers/specs/2026-04-06-reassembly-test-coverage-design.md b/docs/superpowers/specs/2026-04-06-reassembly-test-coverage-design.md new file mode 100644 index 0000000..b105f3e --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-reassembly-test-coverage-design.md @@ -0,0 +1,142 @@ +# Reassembly Engine Test Coverage Design + +**Issue:** #13 — test: add missing reassembly engine test coverage +**Scope:** 7 new integration tests + `ack` parameter on `make_tcp_packet` helper. No production code changes. + +## Problem + +Test gaps identified during PR #10 review. The reassembly engine has untested paths: + +1. **SYN+ACK / bidirectional data** — `on_syn_ack()` (mod.rs:151) never exercised. No test sends data from both directions or verifies `Direction` assignment. +2. **max_flows eviction** — `evict_flows()` (mod.rs:429) never tested. `CloseReason::MemoryPressure` never asserted. +3. **memcap eviction** — memcap threshold check (mod.rs:341) never tested. +4. **FIN teardown (full lifecycle)** — Existing `test_fin_close_total_memory` focuses on memory tracking. No test covers full 3-way handshake + bidirectional data + FIN teardown with `stats.flows_fin` verification. +5. **Overlap anomaly finding** — `OVERLAP_ALERT_THRESHOLD=50` (mod.rs:14) and `findings()` never tested. +6. **Conflicting overlap finding** — `generate_conflicting_overlap_finding()` (mod.rs:474) never tested. +7. **max_segments_per_direction** — Segment count limit (segment.rs:40) never tested at engine level. + +## Approach + +Add 7 tests to `tests/reassembly_engine_tests.rs`. Add `ack: bool` parameter to the shared `make_tcp_packet` helper (update all existing call sites). + +## Changes + +### Helper: `make_tcp_packet` — Add `ack` Parameter + +Add `ack: bool` after `syn` in the parameter list. Pass it through to `TransportInfo::Tcp { ack }`. Update all ~22 existing call sites to pass `false`. + +### Test 1: `test_syn_ack_bidirectional_data` + +Packet sequence: +1. Client SYN (seq 1000, `syn=true`) +2. Server SYN+ACK (seq 2000, `syn=true, ack=true`) +3. Client data "request" (seq 1001) +4. Server data "response" (seq 2001) + +Assertions: +- `stats.flows_partial == 0` (proper handshake, not mid-stream) +- `stats.flows_total == 1` +- 2 data events: first with `Direction::ClientToServer`, second with `Direction::ServerToClient` +- Data content matches "request" and "response" + +### Test 2: `test_max_flows_eviction` + +Config: `max_flows=2`, small `flow_timeout_secs`. + +Packet sequence: +1. Flow A: SYN + data "aaa" (client 10.0.0.1:1000 → server 10.0.0.2:80, timestamp=1) +2. Flow B: SYN + data "bbb" (client 10.0.0.1:2000 → server 10.0.0.2:80, timestamp=2) +3. Flow C: SYN (client 10.0.0.1:3000 → server 10.0.0.2:80, timestamp=3) — triggers eviction + +Assertions: +- `stats.evictions >= 1` +- Close event with `CloseReason::MemoryPressure` present +- Data from evicted flow was flushed (delivered via `on_data`) before close +- Flow C successfully created (flows table not at capacity after eviction) + +### Test 3: `test_memcap_eviction` + +Config: `memcap` set to a small value (e.g., 10 bytes). + +Packet sequence: +1. Flow A: SYN + out-of-order data (stays buffered, 5 bytes) +2. Flow A: more out-of-order data (stays buffered, 5 bytes) — at memcap +3. Flow A: another out-of-order segment (3 bytes) — exceeds memcap, triggers eviction after processing + +Assertions: +- `CloseReason::MemoryPressure` or memcap enforcement observed +- `total_memory()` returns to within memcap bounds + +Note: memcap eviction fires at mod.rs:341 after payload processing. Since there's only one flow, it will evict itself. Alternative: use two flows to make the eviction target clearer. + +### Test 4: `test_full_handshake_fin_teardown` + +Packet sequence: +1. Client SYN (seq 1000) +2. Server SYN+ACK (seq 2000) +3. Client data "hello" (seq 1001) +4. Server data "world" (seq 2001) +5. Client FIN (seq 1006) +6. Server FIN (seq 2006) + +Assertions: +- `stats.flows_fin == 1` +- `CloseReason::Fin` in close events +- Client data "hello" delivered as `ClientToServer` +- Server data "world" delivered as `ServerToClient` +- `total_memory() == 0` + +### Test 5: `test_overlap_anomaly_finding` + +Config: default (threshold is 50). + +Packet sequence: +1. SYN (seq 1000) +2. Data "AAAA" at seq 1001 — original segment +3. 51 duplicate sends of "AAAA" at seq 1001 — each increments `overlap_count` + +After 52 total packets (1 SYN + 1 original + 50 duplicates gives overlap_count=50, then 1 more = 51 > threshold). + +Assertions: +- `findings().len() >= 1` +- Finding contains "Excessive segment overlaps" +- Finding has `ThreatCategory::Anomaly` + +### Test 6: `test_conflicting_overlap_finding` + +Packet sequence: +1. SYN (seq 1000) +2. Data "AAAA" at seq 1001 +3. Data "BBBB" at seq 1001 — same offset, different data + +Assertions: +- `findings().len() >= 1` +- Finding contains "Conflicting TCP segment overlap" +- Finding has `Confidence::High` + +### Test 7: `test_max_segments_per_direction` + +Config: `max_segments_per_direction=5`. + +Packet sequence: +1. SYN (seq 1000) +2. 5 non-contiguous segments: seq 1002 ("a"), 1004 ("b"), 1006 ("c"), 1008 ("d"), 1010 ("e") — each 1 byte with gaps, all buffered +3. 6th segment: seq 1012 ("f") — should be rejected (DepthExceeded) + +Assertions: +- `stats.segments_inserted == 5` (6th rejected) +- Fill the gap: send seq 1001 ("X") — triggers flush of offset 1 + offset 2 ("a") +- Verify existing buffered segments are intact and delivered on flush + +## Files Modified + +| File | Change | +|------|--------| +| `tests/reassembly_engine_tests.rs` | Add `ack` to helper, update call sites, add 7 tests | + +## Not In Scope + +- Production code changes +- Segment-level tests (already comprehensive) +- Benchmark tests +- Small-segment anomaly threshold testing (similar pattern to overlap, lower priority) From 8a26039f34e302f6256d2f76f02510df907387f0 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 22:15:45 -0500 Subject: [PATCH 2/7] refactor: add ack parameter to make_tcp_packet test helper --- tests/reassembly_engine_tests.rs | 174 +++++++++++++++++++++++++++---- 1 file changed, 152 insertions(+), 22 deletions(-) diff --git a/tests/reassembly_engine_tests.rs b/tests/reassembly_engine_tests.rs index 4fe4a15..f2c7ed4 100644 --- a/tests/reassembly_engine_tests.rs +++ b/tests/reassembly_engine_tests.rs @@ -47,6 +47,7 @@ fn make_tcp_packet( seq: u32, payload: &[u8], syn: bool, + ack: bool, fin: bool, rst: bool, ) -> ParsedPacket { @@ -59,7 +60,7 @@ fn make_tcp_packet( dst_port, seq_number: seq, syn, - ack: false, + ack, fin, rst, }, @@ -78,17 +79,34 @@ fn test_three_packet_stream_ordered() { let server = [10, 0, 0, 2]; // SYN - let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + let syn = make_tcp_packet( + client, + 12345, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); reassembler.process_packet(&syn, 1, &mut handler); // Data packets - let p1 = make_tcp_packet(client, 12345, server, 80, 1001, b"aaa", false, false, false); + let p1 = make_tcp_packet( + client, 12345, server, 80, 1001, b"aaa", false, false, false, false, + ); reassembler.process_packet(&p1, 2, &mut handler); - let p2 = make_tcp_packet(client, 12345, server, 80, 1004, b"bbb", false, false, false); + let p2 = make_tcp_packet( + client, 12345, server, 80, 1004, b"bbb", false, false, false, false, + ); reassembler.process_packet(&p2, 3, &mut handler); - let p3 = make_tcp_packet(client, 12345, server, 80, 1007, b"ccc", false, false, false); + let p3 = make_tcp_packet( + client, 12345, server, 80, 1007, b"ccc", false, false, false, false, + ); reassembler.process_packet(&p3, 4, &mut handler); assert_eq!(handler.all_data(), b"aaabbbccc"); @@ -105,18 +123,35 @@ fn test_out_of_order_delivery() { let server = [10, 0, 0, 2]; // SYN - let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + let syn = make_tcp_packet( + client, + 12345, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); reassembler.process_packet(&syn, 1, &mut handler); // Send packets [1, 3, 2] - let p1 = make_tcp_packet(client, 12345, server, 80, 1001, b"aaa", false, false, false); + let p1 = make_tcp_packet( + client, 12345, server, 80, 1001, b"aaa", false, false, false, false, + ); reassembler.process_packet(&p1, 2, &mut handler); - let p3 = make_tcp_packet(client, 12345, server, 80, 1007, b"ccc", false, false, false); + let p3 = make_tcp_packet( + client, 12345, server, 80, 1007, b"ccc", false, false, false, false, + ); reassembler.process_packet(&p3, 3, &mut handler); assert_eq!(handler.data_events.len(), 1); // only p1 flushed - let p2 = make_tcp_packet(client, 12345, server, 80, 1004, b"bbb", false, false, false); + let p2 = make_tcp_packet( + client, 12345, server, 80, 1004, b"bbb", false, false, false, false, + ); reassembler.process_packet(&p2, 4, &mut handler); // Now all three should be flushed @@ -134,7 +169,7 @@ fn test_mid_stream_no_syn() { // Data without SYN let p1 = make_tcp_packet( - client, 12345, server, 80, 5000, b"hello", false, false, false, + client, 12345, server, 80, 5000, b"hello", false, false, false, false, ); reassembler.process_packet(&p1, 1, &mut handler); @@ -154,15 +189,37 @@ fn test_rst_closes_flow() { let client = [10, 0, 0, 1]; let server = [10, 0, 0, 2]; - let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + let syn = make_tcp_packet( + client, + 12345, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); reassembler.process_packet(&syn, 1, &mut handler); let data = make_tcp_packet( - client, 12345, server, 80, 1001, b"data", false, false, false, + client, 12345, server, 80, 1001, b"data", false, false, false, false, ); reassembler.process_packet(&data, 2, &mut handler); - let rst = make_tcp_packet(server, 80, client, 12345, 2000, &[], false, false, true); + let rst = make_tcp_packet( + server, + 80, + client, + 12345, + 2000, + &[], + false, + false, + false, + true, + ); reassembler.process_packet(&rst, 3, &mut handler); assert_eq!(handler.close_events.len(), 1); @@ -179,7 +236,18 @@ fn test_finalize_flushes_remaining() { let client = [10, 0, 0, 1]; let server = [10, 0, 0, 2]; - let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + let syn = make_tcp_packet( + client, + 12345, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); reassembler.process_packet(&syn, 1, &mut handler); let data = make_tcp_packet( @@ -192,6 +260,7 @@ fn test_finalize_flushes_remaining() { false, false, false, + false, ); reassembler.process_packet(&data, 2, &mut handler); @@ -213,7 +282,18 @@ fn test_flow_timeout_expiration() { let client = [10, 0, 0, 1]; let server = [10, 0, 0, 2]; - let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + let syn = make_tcp_packet( + client, + 12345, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); reassembler.process_packet(&syn, 100, &mut handler); // Expire at time 200 (100 seconds later, > 10s timeout) @@ -237,17 +317,32 @@ fn test_total_memory_tracking() { let server = [10, 0, 0, 2]; // SYN — no payload, no memory change - let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + let syn = make_tcp_packet( + client, + 12345, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); reassembler.process_packet(&syn, 1, &mut handler); // Out-of-order segment — buffered (not flushed) - let p2 = make_tcp_packet(client, 12345, server, 80, 1004, b"bbb", false, false, false); + let p2 = make_tcp_packet( + client, 12345, server, 80, 1004, b"bbb", false, false, false, false, + ); reassembler.process_packet(&p2, 2, &mut handler); assert!(handler.data_events.is_empty()); assert_eq!(reassembler.total_memory(), 3); // "bbb" buffered // In-order segment — triggers flush of both - let p1 = make_tcp_packet(client, 12345, server, 80, 1001, b"aaa", false, false, false); + let p1 = make_tcp_packet( + client, 12345, server, 80, 1001, b"aaa", false, false, false, false, + ); reassembler.process_packet(&p1, 3, &mut handler); assert_eq!(handler.all_data(), b"aaabbb"); assert_eq!(reassembler.total_memory(), 0); // all flushed @@ -267,20 +362,55 @@ fn test_fin_close_total_memory() { let server = [10, 0, 0, 2]; // SYN from client - let syn = make_tcp_packet(client, 12345, server, 80, 1000, &[], true, false, false); + let syn = make_tcp_packet( + client, + 12345, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); reassembler.process_packet(&syn, 1, &mut handler); // Out-of-order data — stays buffered (gap at offset 1) - let p2 = make_tcp_packet(client, 12345, server, 80, 1004, b"bbb", false, false, false); + let p2 = make_tcp_packet( + client, 12345, server, 80, 1004, b"bbb", false, false, false, false, + ); reassembler.process_packet(&p2, 2, &mut handler); assert_eq!(reassembler.total_memory(), 3); // FIN from client (first FIN) - let fin1 = make_tcp_packet(client, 12345, server, 80, 1007, &[], false, true, false); + let fin1 = make_tcp_packet( + client, + 12345, + server, + 80, + 1007, + &[], + false, + false, + true, + false, + ); reassembler.process_packet(&fin1, 3, &mut handler); // FIN from server (second FIN → Closed, triggers step 10 removal) - let fin2 = make_tcp_packet(server, 80, client, 12345, 2000, &[], false, true, false); + let fin2 = make_tcp_packet( + server, + 80, + client, + 12345, + 2000, + &[], + false, + false, + true, + false, + ); reassembler.process_packet(&fin2, 4, &mut handler); // Flow removed with buffered-but-not-flushed data — total_memory must be 0 From 226be839b2114a7ce1bbb33fcdf90f8eff1285ed Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 22:17:10 -0500 Subject: [PATCH 3/7] test: add SYN+ACK bidirectional and FIN teardown tests --- tests/reassembly_engine_tests.rs | 177 +++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/tests/reassembly_engine_tests.rs b/tests/reassembly_engine_tests.rs index f2c7ed4..e79edbf 100644 --- a/tests/reassembly_engine_tests.rs +++ b/tests/reassembly_engine_tests.rs @@ -422,3 +422,180 @@ fn test_fin_close_total_memory() { .any(|(_, r)| *r == CloseReason::Fin) ); } + +#[test] +fn test_syn_ack_bidirectional_data() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // 3-way handshake: SYN, SYN+ACK + let syn = make_tcp_packet( + client, + 12345, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); + reassembler.process_packet(&syn, 1, &mut handler); + + let syn_ack = make_tcp_packet( + server, + 80, + client, + 12345, + 2000, + &[], + true, + true, + false, + false, + ); + reassembler.process_packet(&syn_ack, 2, &mut handler); + + // Client sends data + let req = make_tcp_packet( + client, 12345, server, 80, 1001, b"request", false, false, false, false, + ); + reassembler.process_packet(&req, 3, &mut handler); + + // Server sends data + let resp = make_tcp_packet( + server, + 80, + client, + 12345, + 2001, + b"response", + false, + false, + false, + false, + ); + reassembler.process_packet(&resp, 4, &mut handler); + + // Verify proper handshake (not partial/mid-stream) + let stats = reassembler.stats(); + assert_eq!(stats.flows_partial, 0); + assert_eq!(stats.flows_total, 1); + + // Verify bidirectional data with correct directions + assert_eq!(handler.data_events.len(), 2); + assert_eq!(handler.data_events[0].1, Direction::ClientToServer); + assert_eq!(handler.data_events[0].2, b"request"); + assert_eq!(handler.data_events[1].1, Direction::ServerToClient); + assert_eq!(handler.data_events[1].2, b"response"); +} + +#[test] +fn test_full_handshake_fin_teardown() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // Full 3-way handshake + let syn = make_tcp_packet( + client, + 12345, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); + reassembler.process_packet(&syn, 1, &mut handler); + + let syn_ack = make_tcp_packet( + server, + 80, + client, + 12345, + 2000, + &[], + true, + true, + false, + false, + ); + reassembler.process_packet(&syn_ack, 2, &mut handler); + + // Bidirectional data + let req = make_tcp_packet( + client, 12345, server, 80, 1001, b"hello", false, false, false, false, + ); + reassembler.process_packet(&req, 3, &mut handler); + + let resp = make_tcp_packet( + server, 80, client, 12345, 2001, b"world", false, false, false, false, + ); + reassembler.process_packet(&resp, 4, &mut handler); + + // FIN from client + let fin1 = make_tcp_packet( + client, + 12345, + server, + 80, + 1006, + &[], + false, + false, + true, + false, + ); + reassembler.process_packet(&fin1, 5, &mut handler); + + // FIN from server + let fin2 = make_tcp_packet( + server, + 80, + client, + 12345, + 2006, + &[], + false, + false, + true, + false, + ); + reassembler.process_packet(&fin2, 6, &mut handler); + + // Flow closed via FIN + let stats = reassembler.stats(); + assert_eq!(stats.flows_fin, 1); + assert_eq!(reassembler.total_memory(), 0); + + // Close reason is Fin + assert_eq!(handler.close_events.len(), 1); + assert_eq!(handler.close_events[0].1, CloseReason::Fin); + + // Both directions' data delivered + let client_data: Vec<&[u8]> = handler + .data_events + .iter() + .filter(|(_, d, _, _)| *d == Direction::ClientToServer) + .map(|(_, _, data, _)| data.as_slice()) + .collect(); + let server_data: Vec<&[u8]> = handler + .data_events + .iter() + .filter(|(_, d, _, _)| *d == Direction::ServerToClient) + .map(|(_, _, data, _)| data.as_slice()) + .collect(); + assert_eq!(client_data, vec![b"hello".as_slice()]); + assert_eq!(server_data, vec![b"world".as_slice()]); +} From edcf798e00b0eedd35d029c69e074755c4d76240 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 22:23:43 -0500 Subject: [PATCH 4/7] test: add max_flows and memcap eviction tests Adds test_max_flows_eviction and test_memcap_eviction to cover the evict_flows() code path. Both tests use out-of-order segments so data stays buffered, driving total_memory above memcap and ensuring the eviction loop does not short-circuit on its break condition (total_memory <= memcap && flows.len() <= max_flows). --- tests/reassembly_engine_tests.rs | 171 +++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/tests/reassembly_engine_tests.rs b/tests/reassembly_engine_tests.rs index e79edbf..f5ce92e 100644 --- a/tests/reassembly_engine_tests.rs +++ b/tests/reassembly_engine_tests.rs @@ -599,3 +599,174 @@ fn test_full_handshake_fin_teardown() { assert_eq!(client_data, vec![b"hello".as_slice()]); assert_eq!(server_data, vec![b"world".as_slice()]); } + +#[test] +fn test_max_flows_eviction() { + // Verify that the engine evicts the oldest flow when the flow table is full, + // making room for a new flow. + // + // max_flows=2 caps the table size. Both flows carry out-of-order segments + // (buffered, not flushed) so total_memory is non-zero when the third flow + // arrives. memcap=5 is set just below the combined buffered size of both + // flows (3+3=6) so that evict_flows() does not short-circuit: the loop + // break condition is `total_memory <= memcap && flows.len() <= max_flows`, + // and both must be true to stop eviction. With total_memory=6 > memcap=5, + // the oldest flow (A) is evicted before Flow C is admitted. + let config = ReassemblyConfig { + max_flows: 2, + memcap: 5, + ..ReassemblyConfig::default() + }; + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let server = [10, 0, 0, 2]; + + // Flow A (oldest, last_seen=2): SYN + out-of-order data (stays buffered) + let syn_a = make_tcp_packet( + [10, 0, 0, 1], + 1000, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); + reassembler.process_packet(&syn_a, 1, &mut handler); + // seq=1002: offset = 1002-1000 = 2, base_offset = 1 → gap at 1 → buffered + let data_a = make_tcp_packet( + [10, 0, 0, 1], + 1000, + server, + 80, + 1002, + b"aaa", + false, + false, + false, + false, + ); + reassembler.process_packet(&data_a, 2, &mut handler); + assert_eq!(reassembler.total_memory(), 3); // "aaa" buffered, not flushed + + // Flow B (last_seen=4): SYN + out-of-order data (stays buffered) + let syn_b = make_tcp_packet( + [10, 0, 0, 1], + 2000, + server, + 80, + 2000, + &[], + true, + false, + false, + false, + ); + reassembler.process_packet(&syn_b, 3, &mut handler); + // seq=2002: offset = 2002-2000 = 2, base_offset = 1 → gap at 1 → buffered + let data_b = make_tcp_packet( + [10, 0, 0, 1], + 2000, + server, + 80, + 2002, + b"bbb", + false, + false, + false, + false, + ); + reassembler.process_packet(&data_b, 4, &mut handler); + // total_memory = 6 > memcap = 5 → evict_flows() fires, evicts Flow A (oldest) + + // Flow C SYN: after eviction, flows.len()=1 < max_flows=2 → admitted + let syn_c = make_tcp_packet( + [10, 0, 0, 1], + 3000, + server, + 80, + 3000, + &[], + true, + false, + false, + false, + ); + reassembler.process_packet(&syn_c, 5, &mut handler); + + // Eviction occurred + let stats = reassembler.stats(); + assert!(stats.evictions >= 1); + + // MemoryPressure close reason present + assert!( + handler + .close_events + .iter() + .any(|(_, r)| *r == CloseReason::MemoryPressure) + ); + + // All three flows were created at some point + assert_eq!(stats.flows_total, 3); +} + +#[test] +fn test_memcap_eviction() { + let config = ReassemblyConfig { + memcap: 10, + ..ReassemblyConfig::default() + }; + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // Flow A: SYN + out-of-order data (stays buffered because offset 1 is missing) + let syn_a = make_tcp_packet( + client, + 1000, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); + reassembler.process_packet(&syn_a, 1, &mut handler); + let data_a1 = make_tcp_packet( + client, 1000, server, 80, 1002, b"aaaaa", false, false, false, false, + ); + reassembler.process_packet(&data_a1, 2, &mut handler); + assert_eq!(reassembler.total_memory(), 5); + + // Flow B: SYN + out-of-order data that pushes past memcap (5+6=11 > 10) + let syn_b = make_tcp_packet( + client, + 2000, + server, + 80, + 2000, + &[], + true, + false, + false, + false, + ); + reassembler.process_packet(&syn_b, 3, &mut handler); + let data_b1 = make_tcp_packet( + client, 2000, server, 80, 2002, b"bbbbbb", false, false, false, false, + ); + reassembler.process_packet(&data_b1, 4, &mut handler); + // total_memory would be 11 which exceeds memcap=10, triggering eviction + + // Eviction should have fired + let stats = reassembler.stats(); + assert!(stats.evictions >= 1); + assert!(reassembler.total_memory() <= 10); +} From 6e9a9b71fd990efbac5ed35561337156a4d7dc41 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 22:28:52 -0500 Subject: [PATCH 5/7] test: add anomaly finding and max_segments tests --- tests/reassembly_engine_tests.rs | 154 +++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tests/reassembly_engine_tests.rs b/tests/reassembly_engine_tests.rs index f5ce92e..776b809 100644 --- a/tests/reassembly_engine_tests.rs +++ b/tests/reassembly_engine_tests.rs @@ -1,6 +1,7 @@ use std::net::{IpAddr, Ipv4Addr}; use wirerust::decoder::{ParsedPacket, Protocol, TransportInfo}; +use wirerust::findings::{Confidence, ThreatCategory}; use wirerust::reassembly::flow::FlowKey; use wirerust::reassembly::handler::{CloseReason, Direction, StreamHandler}; use wirerust::reassembly::{ReassemblyConfig, TcpReassembler}; @@ -770,3 +771,156 @@ fn test_memcap_eviction() { assert!(stats.evictions >= 1); assert!(reassembler.total_memory() <= 10); } + +#[test] +fn test_overlap_anomaly_finding() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // SYN — establishes ISN=1000, base_offset=1 + let syn = make_tcp_packet( + client, + 12345, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); + reassembler.process_packet(&syn, 1, &mut handler); + + // Out-of-order segment at offset 2 (gap at offset 1 keeps it buffered) + let original = make_tcp_packet( + client, 12345, server, 80, 1002, b"AAAA", false, false, false, false, + ); + reassembler.process_packet(&original, 2, &mut handler); + + // No findings yet + assert!(reassembler.findings().is_empty()); + + // Send 51 duplicates to reach overlap_count=51 (> threshold of 50) + for i in 0..51u32 { + let dup = make_tcp_packet( + client, 12345, server, 80, 1002, b"AAAA", false, false, false, false, + ); + reassembler.process_packet(&dup, 3 + i, &mut handler); + } + + // Overlap anomaly finding should be generated + let findings = reassembler.findings(); + assert!(!findings.is_empty(), "expected overlap anomaly finding"); + let overlap_finding = findings + .iter() + .find(|f| f.summary.contains("Excessive segment overlaps")) + .expect("overlap anomaly finding not found"); + assert_eq!(overlap_finding.category, ThreatCategory::Anomaly); +} + +#[test] +fn test_conflicting_overlap_finding() { + let config = ReassemblyConfig::default(); + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // SYN — establishes ISN=1000, base_offset=1 + let syn = make_tcp_packet( + client, + 12345, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); + reassembler.process_packet(&syn, 1, &mut handler); + + // Out-of-order segment at offset 2 (gap at offset 1 keeps it buffered) + let original = make_tcp_packet( + client, 12345, server, 80, 1002, b"AAAA", false, false, false, false, + ); + reassembler.process_packet(&original, 2, &mut handler); + + // Conflicting retransmission: same seq, different data — triggers ConflictingOverlap + let conflict = make_tcp_packet( + client, 12345, server, 80, 1002, b"BBBB", false, false, false, false, + ); + reassembler.process_packet(&conflict, 3, &mut handler); + + // Conflicting overlap finding should be generated + let findings = reassembler.findings(); + let conflict_finding = findings + .iter() + .find(|f| f.summary.contains("Conflicting TCP segment overlap")) + .expect("conflicting overlap finding not found"); + assert_eq!(conflict_finding.category, ThreatCategory::Anomaly); + assert_eq!(conflict_finding.confidence, Confidence::High); +} + +#[test] +fn test_max_segments_per_direction() { + let config = ReassemblyConfig { + max_segments_per_direction: 5, + ..ReassemblyConfig::default() + }; + let mut reassembler = TcpReassembler::new(config); + let mut handler = RecordingHandler::new(); + + let client = [10, 0, 0, 1]; + let server = [10, 0, 0, 2]; + + // SYN — establishes ISN=1000, base_offset=1 + let syn = make_tcp_packet( + client, + 12345, + server, + 80, + 1000, + &[], + true, + false, + false, + false, + ); + reassembler.process_packet(&syn, 1, &mut handler); + + // 5 non-contiguous segments (gap at offset 1 keeps them buffered). + // seq 1002 → offset 2, seq 1004 → offset 4, etc. Each 1 byte. + for i in 0..5u32 { + let seq = 1002 + (i * 2); + let pkt = make_tcp_packet( + client, 12345, server, 80, seq, b"x", false, false, false, false, + ); + reassembler.process_packet(&pkt, 2 + i, &mut handler); + } + + // All 5 slots used; no data flushed yet (gap at offset 1) + assert!(handler.data_events.is_empty()); + let stats_before = reassembler.stats().segments_inserted; + assert_eq!(stats_before, 5); + + // 6th segment — should be rejected (DepthExceeded: max_segments reached) + let rejected = make_tcp_packet( + client, 12345, server, 80, 1012, b"y", false, false, false, false, + ); + reassembler.process_packet(&rejected, 7, &mut handler); + + // segments_inserted must not have increased + assert_eq!( + reassembler.stats().segments_inserted, + stats_before, + "6th segment should be rejected when max_segments_per_direction is reached" + ); +} From 03180a88123605daff4db07ee4cc0571d0a31b85 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 22:36:27 -0500 Subject: [PATCH 6/7] test: strengthen assertions from PR review findings - Verify eviction order by FlowKey in test_max_flows_eviction - Add CloseReason::MemoryPressure assertion to test_memcap_eviction - Assert confidence, verdict, MITRE technique on overlap anomaly finding - Verify buffered segments survive max_segments rejection via memory accounting --- tests/reassembly_engine_tests.rs | 45 +++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/tests/reassembly_engine_tests.rs b/tests/reassembly_engine_tests.rs index 776b809..5ea5991 100644 --- a/tests/reassembly_engine_tests.rs +++ b/tests/reassembly_engine_tests.rs @@ -1,7 +1,7 @@ use std::net::{IpAddr, Ipv4Addr}; use wirerust::decoder::{ParsedPacket, Protocol, TransportInfo}; -use wirerust::findings::{Confidence, ThreatCategory}; +use wirerust::findings::{Confidence, ThreatCategory, Verdict}; use wirerust::reassembly::flow::FlowKey; use wirerust::reassembly::handler::{CloseReason, Direction, StreamHandler}; use wirerust::reassembly::{ReassemblyConfig, TcpReassembler}; @@ -712,6 +712,23 @@ fn test_max_flows_eviction() { // All three flows were created at some point assert_eq!(stats.flows_total, 3); + + // Verify eviction order: Flow A (oldest, last_seen=2) was evicted, not Flow B + let flow_a_key = FlowKey::new( + IpAddr::V4(Ipv4Addr::from([10, 0, 0, 1])), + 1000, + IpAddr::V4(Ipv4Addr::from(server)), + 80, + ); + let evicted = handler + .close_events + .iter() + .find(|(_, r)| *r == CloseReason::MemoryPressure) + .expect("MemoryPressure close event must exist"); + assert_eq!( + evicted.0, flow_a_key, + "oldest flow (A) should be evicted first" + ); } #[test] @@ -770,6 +787,15 @@ fn test_memcap_eviction() { let stats = reassembler.stats(); assert!(stats.evictions >= 1); assert!(reassembler.total_memory() <= 10); + + // CloseReason::MemoryPressure must be emitted + assert!( + handler + .close_events + .iter() + .any(|(_, r)| *r == CloseReason::MemoryPressure), + "memcap eviction must emit CloseReason::MemoryPressure" + ); } #[test] @@ -821,6 +847,9 @@ fn test_overlap_anomaly_finding() { .find(|f| f.summary.contains("Excessive segment overlaps")) .expect("overlap anomaly finding not found"); assert_eq!(overlap_finding.category, ThreatCategory::Anomaly); + assert_eq!(overlap_finding.confidence, Confidence::Medium); + assert_eq!(overlap_finding.verdict, Verdict::Likely); + assert_eq!(overlap_finding.mitre_technique.as_deref(), Some("T1036")); } #[test] @@ -923,4 +952,18 @@ fn test_max_segments_per_direction() { stats_before, "6th segment should be rejected when max_segments_per_direction is reached" ); + + // Verify existing buffered segments survive rejection (non-destructive). + // The 5 segments at offsets 2,4,6,8,10 are non-contiguous with base_offset=1, + // so flush_contiguous won't deliver them. Verify via memory accounting: + // total_memory should still reflect all 5 bytes. + assert_eq!( + reassembler.total_memory(), + 5, + "5 buffered segments (1 byte each) must survive after rejection" + ); + + // Finalize cleans up — total_memory drops to 0 + reassembler.finalize(&mut handler); + assert_eq!(reassembler.total_memory(), 0); } From 7c9589074d7fcbe5926339c7801281d4c6fbb0c4 Mon Sep 17 00:00:00 2001 From: Zious Date: Mon, 6 Apr 2026 22:46:40 -0500 Subject: [PATCH 7/7] docs: fix comment and spec inaccuracies from Copilot review - Replace "3-way handshake" with "SYN + SYN+ACK handshake" in test comments (engine transitions to Established on SYN+ACK, no third ACK needed) - Update spec: max_flows test uses memcap=5 and out-of-order data, not flow_timeout_secs - Update spec: memcap test uses two flows, not single-flow self-eviction - Update spec: overlap test uses seq 1002 (offset 2) not seq 1001, and 53 total packets not 52 - Update spec: max_segments test verifies via total_memory + finalize, not gap-fill flush --- ...6-04-06-reassembly-test-coverage-design.md | 38 +++++++++---------- tests/reassembly_engine_tests.rs | 4 +- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/docs/superpowers/specs/2026-04-06-reassembly-test-coverage-design.md b/docs/superpowers/specs/2026-04-06-reassembly-test-coverage-design.md index b105f3e..671fbda 100644 --- a/docs/superpowers/specs/2026-04-06-reassembly-test-coverage-design.md +++ b/docs/superpowers/specs/2026-04-06-reassembly-test-coverage-design.md @@ -10,7 +10,7 @@ Test gaps identified during PR #10 review. The reassembly engine has untested pa 1. **SYN+ACK / bidirectional data** — `on_syn_ack()` (mod.rs:151) never exercised. No test sends data from both directions or verifies `Direction` assignment. 2. **max_flows eviction** — `evict_flows()` (mod.rs:429) never tested. `CloseReason::MemoryPressure` never asserted. 3. **memcap eviction** — memcap threshold check (mod.rs:341) never tested. -4. **FIN teardown (full lifecycle)** — Existing `test_fin_close_total_memory` focuses on memory tracking. No test covers full 3-way handshake + bidirectional data + FIN teardown with `stats.flows_fin` verification. +4. **FIN teardown (full lifecycle)** — Existing `test_fin_close_total_memory` focuses on memory tracking. No test covers SYN+ACK handshake + bidirectional data + FIN teardown with `stats.flows_fin` verification. 5. **Overlap anomaly finding** — `OVERLAP_ALERT_THRESHOLD=50` (mod.rs:14) and `findings()` never tested. 6. **Conflicting overlap finding** — `generate_conflicting_overlap_finding()` (mod.rs:474) never tested. 7. **max_segments_per_direction** — Segment count limit (segment.rs:40) never tested at engine level. @@ -41,34 +41,32 @@ Assertions: ### Test 2: `test_max_flows_eviction` -Config: `max_flows=2`, small `flow_timeout_secs`. +Config: `max_flows=2`, `memcap=5`. Packet sequence: -1. Flow A: SYN + data "aaa" (client 10.0.0.1:1000 → server 10.0.0.2:80, timestamp=1) -2. Flow B: SYN + data "bbb" (client 10.0.0.1:2000 → server 10.0.0.2:80, timestamp=2) -3. Flow C: SYN (client 10.0.0.1:3000 → server 10.0.0.2:80, timestamp=3) — triggers eviction +1. Flow A: SYN + out-of-order data "aaa" (client 10.0.0.1:1000 → server 10.0.0.2:80, timestamp=1) — buffered, not contiguous +2. Flow B: SYN + out-of-order data "bbb" (client 10.0.0.1:2000 → server 10.0.0.2:80, timestamp=3) — buffered, not contiguous; total_memory=6 > memcap=5 triggers eviction +3. Flow C: SYN (client 10.0.0.1:3000 → server 10.0.0.2:80, timestamp=5) — admitted after eviction Assertions: - `stats.evictions >= 1` - Close event with `CloseReason::MemoryPressure` present -- Data from evicted flow was flushed (delivered via `on_data`) before close +- Evicted flow identified by FlowKey (oldest flow evicted first) - Flow C successfully created (flows table not at capacity after eviction) ### Test 3: `test_memcap_eviction` -Config: `memcap` set to a small value (e.g., 10 bytes). +Config: `memcap=10`. Packet sequence: 1. Flow A: SYN + out-of-order data (stays buffered, 5 bytes) -2. Flow A: more out-of-order data (stays buffered, 5 bytes) — at memcap -3. Flow A: another out-of-order segment (3 bytes) — exceeds memcap, triggers eviction after processing +2. Flow B: SYN + out-of-order data (stays buffered, 6 bytes) — combined total_memory=11 > memcap=10, triggers eviction Assertions: -- `CloseReason::MemoryPressure` or memcap enforcement observed +- `stats.evictions >= 1` +- `CloseReason::MemoryPressure` emitted - `total_memory()` returns to within memcap bounds -Note: memcap eviction fires at mod.rs:341 after payload processing. Since there's only one flow, it will evict itself. Alternative: use two flows to make the eviction target clearer. - ### Test 4: `test_full_handshake_fin_teardown` Packet sequence: @@ -92,22 +90,22 @@ Config: default (threshold is 50). Packet sequence: 1. SYN (seq 1000) -2. Data "AAAA" at seq 1001 — original segment -3. 51 duplicate sends of "AAAA" at seq 1001 — each increments `overlap_count` +2. Out-of-order data "AAAA" at seq 1002 (offset 2, stays buffered due to gap at offset 1) +3. 51 duplicate sends of "AAAA" at seq 1002 — each increments `overlap_count` -After 52 total packets (1 SYN + 1 original + 50 duplicates gives overlap_count=50, then 1 more = 51 > threshold). +After 53 total packets (1 SYN + 1 original + 51 duplicates), `overlap_count` reaches 51 > threshold of 50. Assertions: - `findings().len() >= 1` - Finding contains "Excessive segment overlaps" -- Finding has `ThreatCategory::Anomaly` +- Finding has `ThreatCategory::Anomaly`, `Confidence::Medium`, `Verdict::Likely`, MITRE technique "T1036" ### Test 6: `test_conflicting_overlap_finding` Packet sequence: 1. SYN (seq 1000) -2. Data "AAAA" at seq 1001 -3. Data "BBBB" at seq 1001 — same offset, different data +2. Out-of-order data "AAAA" at seq 1002 (stays buffered due to gap at offset 1) +3. Data "BBBB" at seq 1002 — same offset, different data Assertions: - `findings().len() >= 1` @@ -125,8 +123,8 @@ Packet sequence: Assertions: - `stats.segments_inserted == 5` (6th rejected) -- Fill the gap: send seq 1001 ("X") — triggers flush of offset 1 + offset 2 ("a") -- Verify existing buffered segments are intact and delivered on flush +- Rejection is non-destructive: `total_memory` still reflects 5 buffered bytes +- `finalize()` cleans up — `total_memory` drops to 0 ## Files Modified diff --git a/tests/reassembly_engine_tests.rs b/tests/reassembly_engine_tests.rs index 5ea5991..104834b 100644 --- a/tests/reassembly_engine_tests.rs +++ b/tests/reassembly_engine_tests.rs @@ -433,7 +433,7 @@ fn test_syn_ack_bidirectional_data() { let client = [10, 0, 0, 1]; let server = [10, 0, 0, 2]; - // 3-way handshake: SYN, SYN+ACK + // SYN + SYN+ACK handshake (engine transitions to Established on SYN+ACK) let syn = make_tcp_packet( client, 12345, @@ -505,7 +505,7 @@ fn test_full_handshake_fin_teardown() { let client = [10, 0, 0, 1]; let server = [10, 0, 0, 2]; - // Full 3-way handshake + // SYN + SYN+ACK handshake let syn = make_tcp_packet( client, 12345,