diff --git a/docs/demo-evidence/STORY-146/AC-146-001-002-004-006-test-suite.gif b/docs/demo-evidence/STORY-146/AC-146-001-002-004-006-test-suite.gif new file mode 100644 index 00000000..4c5305e4 Binary files /dev/null and b/docs/demo-evidence/STORY-146/AC-146-001-002-004-006-test-suite.gif differ diff --git a/docs/demo-evidence/STORY-146/AC-146-001-002-004-006-test-suite.tape b/docs/demo-evidence/STORY-146/AC-146-001-002-004-006-test-suite.tape new file mode 100644 index 00000000..b41bb691 --- /dev/null +++ b/docs/demo-evidence/STORY-146/AC-146-001-002-004-006-test-suite.tape @@ -0,0 +1,53 @@ +Output AC-146-001-002-004-006-test-suite.gif +Output AC-146-001-002-004-006-test-suite.webm + +Require cargo + +Set Shell "bash" +Set FontFamily "Menlo" +Set FontSize 13 +Set Width 1200 +Set Height 700 +Set Theme "Dracula" +Set Padding 20 +Set WaitTimeout 120s + +# AC-146-001 (fill_buf_for_testing seam), AC-146-002 (counter increments on tail-drop), +# AC-146-004 (accessor buffer_saturation_drop_count), AC-146-006 (both directions, +# same aggregate counter) — all covered by the story_146 test module. +# +# The saturation path requires the buffer to hit MAX_BUF=65536 which is only +# reachable via fill_buf_for_testing; the 8-test suite exercises: +# partial-drop, full-drop, no-drop boundary, exact-fit boundary, +# both-directions, counter persistence across flow close, and summarize value equality. +# +# Success path: all 8 story_146 tests pass. +# Error path: misspelled module name -> 0 tests run (demonstrates test filter precision). + +Hide +Type "cd /Users/zious/Documents/GITHUB/wirerust/.worktrees/story-146-tls-buffer-saturation-telemetry" +Enter +Sleep 300ms +Show + +Type "echo '=== STORY-146: buffer_saturation_drops counter AC-001/002/004/006 ==='" +Enter +Sleep 600ms + +Type "echo '--- Success path: all 8 story_146 tests pass ---'" +Enter +Sleep 300ms + +Type "cargo test --test tls_analyzer_tests story_146 2>&1" +Enter +Wait+Screen /test result/ +Sleep 2s + +Type "echo '--- Error path: misspelled module -> 0 tests run ---'" +Enter +Sleep 300ms + +Type "cargo test --test tls_analyzer_tests story_146_typo 2>&1" +Enter +Wait+Screen /test result/ +Sleep 2s diff --git a/docs/demo-evidence/STORY-146/AC-146-001-002-004-006-test-suite.webm b/docs/demo-evidence/STORY-146/AC-146-001-002-004-006-test-suite.webm new file mode 100644 index 00000000..5d267ab4 Binary files /dev/null and b/docs/demo-evidence/STORY-146/AC-146-001-002-004-006-test-suite.webm differ diff --git a/docs/demo-evidence/STORY-146/AC-146-005-summarize-key-present.gif b/docs/demo-evidence/STORY-146/AC-146-005-summarize-key-present.gif new file mode 100644 index 00000000..78d0e258 Binary files /dev/null and b/docs/demo-evidence/STORY-146/AC-146-005-summarize-key-present.gif differ diff --git a/docs/demo-evidence/STORY-146/AC-146-005-summarize-key-present.tape b/docs/demo-evidence/STORY-146/AC-146-005-summarize-key-present.tape new file mode 100644 index 00000000..e1b52efe --- /dev/null +++ b/docs/demo-evidence/STORY-146/AC-146-005-summarize-key-present.tape @@ -0,0 +1,47 @@ +Output AC-146-005-summarize-key-present.gif +Output AC-146-005-summarize-key-present.webm + +Require cargo + +Set Shell "bash" +Set FontFamily "Menlo" +Set FontSize 13 +Set Width 1200 +Set Height 700 +Set Theme "Dracula" +Set Padding 20 +Set WaitTimeout 60s + +# AC-146-005: summarize() exposes "buffer_saturation_drops" in the detail map. +# Success path: wirerust --tls analyze on a real TLS 1.2 pcap shows the new +# "buffer_saturation_drops: 0" key in ANALYZER: TLS section. +# Error path: running without --tls omits the TLS analyzer section entirely, +# demonstrating the key is exclusively from the TLS analyzer. + +Hide +Type "cd /Users/zious/Documents/GITHUB/wirerust/.worktrees/story-146-tls-buffer-saturation-telemetry" +Enter +Sleep 300ms +Show + +Type "echo '=== STORY-146: buffer_saturation_drops key in summarize() ==='" +Enter +Sleep 600ms + +Type "echo '--- Success path: --tls flag shows buffer_saturation_drops ---'" +Enter +Sleep 300ms + +Type "./target/release/wirerust --no-color analyze --tls tests/fixtures/tls12-aes256gcm.pcap" +Enter +Wait+Screen /buffer_saturation_drops/ +Sleep 2s + +Type "echo '--- Error path: without --tls, no TLS analyzer section ---'" +Enter +Sleep 300ms + +Type "./target/release/wirerust --no-color analyze tests/fixtures/tls12-aes256gcm.pcap" +Enter +Wait+Screen /WIRERUST TRIAGE/ +Sleep 2s diff --git a/docs/demo-evidence/STORY-146/AC-146-005-summarize-key-present.webm b/docs/demo-evidence/STORY-146/AC-146-005-summarize-key-present.webm new file mode 100644 index 00000000..cd522b6f Binary files /dev/null and b/docs/demo-evidence/STORY-146/AC-146-005-summarize-key-present.webm differ diff --git a/docs/demo-evidence/STORY-146/evidence-report.md b/docs/demo-evidence/STORY-146/evidence-report.md new file mode 100644 index 00000000..692952ab --- /dev/null +++ b/docs/demo-evidence/STORY-146/evidence-report.md @@ -0,0 +1,56 @@ +# Demo Evidence Report — STORY-146 + +**Story:** TLS buffer-saturation telemetry (`buffer_saturation_drops` counter) +**Story ID:** STORY-146 +**Recorded:** 2026-06-30 +**Toolchain:** VHS 0.11.0 · wirerust release binary · cargo test + +--- + +## Coverage Map + +| Acceptance Criteria | Demo Artifact | What It Shows | +|---------------------|--------------|---------------| +| AC-146-005 (summarize key present, value=0 when no saturation) | `AC-146-005-summarize-key-present.webm` / `.gif` | `wirerust --no-color analyze --tls tests/fixtures/tls12-aes256gcm.pcap` — terminal output includes `ANALYZER: TLS` section with `buffer_saturation_drops: 0`; without `--tls` the section is absent (error path). | +| AC-146-001 (`fill_buf_for_testing` seam), AC-146-002 (counter increments on tail-drop), AC-146-004 (accessor `buffer_saturation_drop_count`), AC-146-006 (both directions, same aggregate counter) | `AC-146-001-002-004-006-test-suite.webm` / `.gif` | `cargo test --test tls_analyzer_tests story_146` — all 8 tests pass, including `test_BC_2_07_043_buffer_saturation_observable`, `test_BC_2_07_043_buffer_saturation_full_drop`, `test_BC_2_07_043_both_directions_increment_same_counter`, `test_BC_2_07_043_counter_persists_across_flows`, and `test_BC_2_07_043_summarize_value_equals_drop_count`; misspelled module name shows 0 tests run (error path). | + +--- + +## Artifacts + +``` +demos/story-146/ + AC-146-005-summarize-key-present.tape — VHS script source + AC-146-005-summarize-key-present.gif — 379K GIF recording + AC-146-005-summarize-key-present.webm — 288K WebM recording + + AC-146-001-002-004-006-test-suite.tape — VHS script source + AC-146-001-002-004-006-test-suite.gif — 219K GIF recording + AC-146-001-002-004-006-test-suite.webm — 256K WebM recording +``` + +--- + +## Notes on Coverage Approach + +AC-146-002 and AC-146-006 require the per-direction TCP segment buffer to reach +MAX_BUF=65,536 bytes before a drop occurs. This threshold is not reachable with the +existing small pcap fixtures without engineering a synthetic 65K+ TLS capture. +The `fill_buf_for_testing` seam (AC-146-001) exists precisely to make this path +testable: it parks the buffer at capacity so a single subsequent `on_data` call +triggers a drop. The `story_146` test module exercises this seam directly, making +the test suite the correct evidence vehicle for AC-146-001/002/004/006. +AC-146-005 is demonstrated via the live CLI since `buffer_saturation_drops: 0` +is always present in `summarize()` output, even with no saturation events. + +--- + +## AC Coverage Status + +| AC | Covered | Vehicle | +|----|---------|---------| +| AC-146-001 | Yes | test suite (8 passing) | +| AC-146-002 | Yes | test suite (`test_BC_2_07_043_buffer_saturation_observable`, `_full_drop`) | +| AC-146-004 | Yes | test suite (`buffer_saturation_drop_count()` accessor calls) | +| AC-146-005 | Yes | live CLI on `tls12-aes256gcm.pcap` | +| AC-146-006 | Yes | test suite (`test_BC_2_07_043_both_directions_increment_same_counter`) | diff --git a/src/analyzer/tls.rs b/src/analyzer/tls.rs index c33cfa36..1db3f2e9 100644 --- a/src/analyzer/tls.rs +++ b/src/analyzer/tls.rs @@ -347,6 +347,16 @@ pub struct TlsAnalyzer { /// existing `truncated_records` aggregate counter pattern. /// AC-144-001 / ADR-011 Decision 1 / BC-2.07.039 Invariant 5. handshake_reassembly_overflows: u64, + /// Aggregate count of per-direction TCP-segment buffer tail-drop events. + /// + /// Incremented each time `on_data` detects that the incoming data length + /// exceeds the remaining capacity of `client_buf` or `server_buf` + /// (`data.len() > remaining` where `remaining = MAX_BUF.saturating_sub(buf.len())`). + /// This is an AGGREGATE counter on `TlsAnalyzer` — NOT on `TlsFlowState` — and is + /// NOT reset at `on_flow_close`. Mirrors the `truncated_records` / `handshake_reassembly_overflows` + /// aggregate counter pattern. + /// AC-146-001 / BC-2.07.043 Invariants 1–3. + buffer_saturation_drops: u64, all_findings: Vec, } @@ -369,6 +379,7 @@ impl TlsAnalyzer { parse_errors: 0, truncated_records: 0, handshake_reassembly_overflows: 0, + buffer_saturation_drops: 0, all_findings: Vec::new(), } } @@ -1111,6 +1122,10 @@ impl StreamHandler for TlsAnalyzer { return; } + // AC-146-002 / BC-2.07.043 Invariant 4: compute did_drop INSIDE the &mut state + // block (borrow-constraint mandated); increment self.buffer_saturation_drops + // AFTER the block closes (mutable borrow on self.flows released). + let did_drop; { let state = self .flows @@ -1122,6 +1137,12 @@ impl StreamHandler for TlsAnalyzer { match direction { Direction::ClientToServer => { let remaining = MAX_BUF.saturating_sub(state.client_buf.len()); + // AC-146-002: detect tail-drop condition (strictly greater, NOT >=). + // data.len() > remaining covers both partial-drop (remaining>0, 1+ + // bytes truncated) and full-drop (remaining==0) paths. The form + // `to_copy < data.len()` would miss the full-drop case because to_copy + // is computed only inside the `if remaining > 0` arm (ADR-011 C-3). + did_drop = data.len() > remaining; if remaining > 0 { let to_copy = data.len().min(remaining); state.client_buf.extend_from_slice(&data[..to_copy]); @@ -1129,6 +1150,9 @@ impl StreamHandler for TlsAnalyzer { } Direction::ServerToClient => { let remaining = MAX_BUF.saturating_sub(state.server_buf.len()); + // AC-146-002: symmetric drop detection for the ServerToClient arm. + // Same aggregate counter — BC-2.07.043 Postcondition 3. + did_drop = data.len() > remaining; if remaining > 0 { let to_copy = data.len().min(remaining); state.server_buf.extend_from_slice(&data[..to_copy]); @@ -1136,6 +1160,16 @@ impl StreamHandler for TlsAnalyzer { } } } + // AC-146-002: increment AFTER the &mut state block closes (borrow released). + // Placement is between the buffer-append block and the try_parse_records call + // (ADR-011 Decision 1 / BC-2.07.043 Invariant 4). Byte-drop semantics unchanged. + if did_drop { + // SEC-003: saturating_add mirrors sibling `handshake_reassembly_overflows` + // increments — avoids theoretical overflow-check panic under the release + // profile's `overflow-checks = true`. Counter saturation at u64::MAX is safe + // and intentional for an aggregate diagnostic. + self.buffer_saturation_drops = self.buffer_saturation_drops.saturating_add(1); + } self.try_parse_records(flow_key, direction); } @@ -1196,6 +1230,13 @@ impl StreamAnalyzer for TlsAnalyzer { "handshake_reassembly_overflows".to_string(), serde_json::json!(self.handshake_reassembly_overflows), ); + // AC-146-005: surface `buffer_saturation_drops` in the detail map. + // Key ALWAYS present even when count==0 (EC-008 / BC-2.07.043 Postcondition 4). + // Mirrors `truncated_records` and `handshake_reassembly_overflows` pattern above. + detail.insert( + "buffer_saturation_drops".to_string(), + serde_json::json!(self.buffer_saturation_drops), + ); AnalysisSummary { analyzer_name: self.name().to_string(), @@ -1389,6 +1430,57 @@ impl TlsAnalyzer { pub fn handshake_reassembly_overflow_count(&self) -> u64 { self.handshake_reassembly_overflows } + + // ── STORY-146 accessor + test seam (AC-146-001) ─────────────────────────── + // + // During the RED gate, `buffer_saturation_drop_count` and `fill_buf_for_testing` + // carried `todo!()` bodies to enforce test failures before implementation. + // Both are now fully implemented: `buffer_saturation_drop_count` reads + // `self.buffer_saturation_drops` directly (mirroring the + // `truncated_record_count` / `handshake_reassembly_overflow_count` pattern), + // and `fill_buf_for_testing` fills the per-direction TCP-segment buffer to an + // exact byte count so tests can drive the full-drop path without live traffic. + + /// Public accessor: aggregate count of per-direction buffer saturation tail-drop events. + /// + /// Reads `self.buffer_saturation_drops` directly. Mirrors the existing + /// `truncated_record_count()` and `handshake_reassembly_overflow_count()` public + /// accessor pattern (AC-146-001 / BC-2.07.043 Invariants 1–3). Read-only; do not + /// use for drop-prevention decisions. + pub fn buffer_saturation_drop_count(&self) -> u64 { + self.buffer_saturation_drops + } + + /// Test-only seam: fill the per-direction TCP-segment buffer to exactly `n` bytes. + /// + /// Fills `client_buf` (for `Direction::ClientToServer`) or `server_buf` + /// (for `Direction::ServerToClient`) of the given flow to exactly `n` bytes, + /// creating the flow state entry if it does not yet exist. + /// + /// Precondition: `n <= MAX_BUF`. Required to exercise the full-drop path + /// (`remaining==0`, EC-002) since that state is not reachable via the public + /// `on_data` API alone without first filling the buffer with real TLS data. + /// + /// Signature uses `flow_key: &FlowKey` by reference, matching the convention + /// of all five sibling TLS test seams. AC-146-001 / BC-2.07.043 Architecture Anchor. + /// MUST NOT be called from production code. + #[doc(hidden)] + pub fn fill_buf_for_testing(&mut self, flow_key: &FlowKey, direction: Direction, n: usize) { + debug_assert!( + n <= MAX_BUF, + "fill_buf_for_testing: n={n} exceeds MAX_BUF={MAX_BUF}" + ); + let state = self + .flows + .entry(flow_key.clone()) + .or_insert_with(TlsFlowState::new); + let buf = match direction { + Direction::ClientToServer => &mut state.client_buf, + Direction::ServerToClient => &mut state.server_buf, + }; + buf.clear(); + buf.resize(n, 0u8); + } } // ── JA3 / JA3S property tests (LESSON-P2.04) ───────────────────────────────── diff --git a/tests/tls_analyzer_tests.rs b/tests/tls_analyzer_tests.rs index faf13532..fd91c0b1 100644 --- a/tests/tls_analyzer_tests.rs +++ b/tests/tls_analyzer_tests.rs @@ -929,11 +929,14 @@ fn test_summarize_output() { let detail = &summary.detail; - // AC-009 / BC-2.07.031 postconditions 3-9 + AC-144-003: - // EXACT 8-key set — no more, no fewer (BTreeMap ordering enforced below). + // AC-009 / BC-2.07.031 postconditions 3-9 + AC-144-003 + AC-146-005: + // EXACT 9-key set — no more, no fewer (BTreeMap ordering enforced below). // Updated from 7→8 in STORY-144 to include `handshake_reassembly_overflows` // (BC-2.07.039 Postcondition 7 / AC-144-003). + // Updated from 8→9 in STORY-146 to include `buffer_saturation_drops` + // (BC-2.07.043 Postcondition 4 / AC-146-005; key always present even when count==0). let required_keys = [ + "buffer_saturation_drops", "cipher_suites", "handshake_reassembly_overflows", "ja3_hashes", @@ -951,8 +954,9 @@ fn test_summarize_output() { } assert_eq!( detail.len(), - 8, - "AC-009 (BC-2.07.031 pc3-9 + AC-144-003): detail must have EXACTLY 8 keys, got: {:?}", + 9, + "AC-009 (BC-2.07.031 pc3-9 + AC-144-003 + AC-146-005): detail must have EXACTLY 9 keys, \ + got: {:?}", detail.keys().collect::>() ); @@ -10954,3 +10958,436 @@ mod story_145 { ); } } + +// ── STORY-146 VP-040 Red-Gate tests ────────────────────────────────────────── +// +// DF-TEST-NAMESPACE-001: ALL 8 test functions for STORY-146 (6 canonical VP-040 Red-Gate + 2 EC-coverage) are inside +// this `mod story_146` wrapper. No new flat-root tests are added for this story. +// +// These tests verify BC-2.07.043 v1.3 (Per-Direction Buffer Saturation +// Tail-Drop Is Observable via `buffer_saturation_drops` Counter). +// +// GREEN status: all 8 tests pass. +// - Tests calling `buffer_saturation_drop_count()` read the implemented counter. +// - Tests calling `fill_buf_for_testing()` exercise the implemented fill seam. +// - `test_BC_2_07_043_summarize_value_equals_drop_count` verifies that +// `"buffer_saturation_drops"` is present and accurate in the `summarize()` detail map. +// +// Reconciliation (grep run before declaring helpers): +// build_server_hello → EXISTS at flat root (line 137) +// build_client_hello → EXISTS at flat root (line 16) +// wrap_as_tls_record → ONLY in mod story_144/story_145 (private) — re-declared here +// make_test_flow_key → ONLY in mod story_144/story_145 (private) — re-declared here +// all_findings_len_for_testing → EXISTS at tls.rs seam scope — used directly +mod story_146 { + use std::net::IpAddr; + use wirerust::analyzer::tls::TlsAnalyzer; + use wirerust::reassembly::flow::FlowKey; + use wirerust::reassembly::handler::{CloseReason, Direction, StreamAnalyzer, StreamHandler}; + + // ── Local test helpers ──────────────────────────────────────────────────── + + /// Create a `FlowKey` varied by `seed` so cross-flow and independent-flow + /// tests can use distinct keys without collision. + /// + /// Re-declared locally per DF-TEST-NAMESPACE-001 (identical to mod story_144/145 copy). + fn make_test_flow_key(seed: u8) -> FlowKey { + FlowKey::new( + IpAddr::from([10, 146, 0, seed]), + 49000u16.wrapping_add(seed as u16), + IpAddr::from([10, 146, 1, seed]), + 443, + ) + } + + // MAX_BUF = 65,536 bytes (BC-2.07.005 / ADR-011). The real constant is + // private to `src/analyzer/tls.rs`; we use the literal here with a comment + // so any change to the real constant would break tests visibly. + const MAX_BUF: usize = 65_536; + + // ── VP-040 Sub-A (partial-drop path) ───────────────────────────────────── + + /// VP-040 Sub-A (unit): delivering 65,537 bytes to an empty per-direction + /// buffer causes a partial tail-drop (1 byte discarded), increments + /// `buffer_saturation_drops` by exactly 1, and leaves `parse_errors` + /// unchanged. + /// + /// Setup: fresh `TlsAnalyzer`; empty buffer (no prior `on_data`). + /// Action: `on_data` with 65,537 raw bytes (C2S direction). + /// Buffer remaining before call = MAX_BUF = 65,536. Since 65,537 > 65,536, + /// the tail-drop condition fires. 1 byte is dropped; 65,536 bytes are + /// appended. + /// + /// Non-tautology: if the `did_drop` detection + `buffer_saturation_drops.saturating_add(1)` + /// increment were absent, `buffer_saturation_drop_count()` would return 0 and + /// the assertion `drops_after == drops_before + 1` would fail. + /// + /// Traces to: BC-2.07.043 v1.3 Postcondition 1 (partial-drop path); AC-146-002. + // DF-AC-TEST-NAME-SYNC-001: canonical name verbatim per VP-040 table. + #[allow(non_snake_case)] + #[test] + fn test_BC_2_07_043_buffer_saturation_observable() { + let fk = make_test_flow_key(60); + let mut analyzer = TlsAnalyzer::new(); + + let drops_before = analyzer.buffer_saturation_drop_count(); + let parse_errors_before = analyzer.parse_error_count(); + + // 65,537 bytes to an empty buffer: remaining == 65,536; 1 byte dropped. + let data = vec![0x00u8; MAX_BUF + 1]; + analyzer.on_data(&fk, Direction::ClientToServer, &data, 0, 0); + + assert_eq!( + analyzer.buffer_saturation_drop_count(), + drops_before + 1, + "partial-drop (65,537 bytes to empty buffer): buffer_saturation_drops \ + must increment by exactly 1 (drops_before={drops_before})" + ); + assert_eq!( + analyzer.parse_error_count(), + parse_errors_before, + "partial-drop: parse_error_count must NOT change on buffer saturation \ + (parse_errors_before={parse_errors_before})" + ); + } + + // ── VP-040 Sub-A (full-drop path) ──────────────────────────────────────── + + /// VP-040 Sub-A (unit): delivering any bytes to a buffer already at MAX_BUF + /// capacity (remaining == 0) causes a full tail-drop and increments + /// `buffer_saturation_drops` by exactly 1. + /// + /// Setup: `fill_buf_for_testing` seam parks the C2S buffer at exactly MAX_BUF + /// bytes (remaining == 0). + /// Action: `on_data` with 1,000 raw bytes. + /// Since 1,000 > 0, the tail-drop condition fires; 0 bytes are appended. + /// + /// Non-tautology: if the full-drop path (`remaining==0` branch) were absent + /// or misdetected (using `to_copy < data.len()` instead of + /// `data.len() > remaining`), the counter would not increment and the + /// assertion would fail. + /// + /// Traces to: BC-2.07.043 v1.3 Postcondition 1 (full-drop path EC-002); + /// AC-146-001 (`fill_buf_for_testing` seam); AC-146-002. + // DF-AC-TEST-NAME-SYNC-001: canonical name verbatim per VP-040 table. + #[allow(non_snake_case)] + #[test] + fn test_BC_2_07_043_buffer_saturation_full_drop() { + let fk = make_test_flow_key(61); + let mut analyzer = TlsAnalyzer::new(); + + // Park the C2S buffer at exactly MAX_BUF (remaining == 0). + analyzer.fill_buf_for_testing(&fk, Direction::ClientToServer, MAX_BUF); + + let drops_before = analyzer.buffer_saturation_drop_count(); + + // Any delivery now triggers full-drop: 1,000 > 0. + let data = vec![0x00u8; 1_000]; + analyzer.on_data(&fk, Direction::ClientToServer, &data, 0, 0); + + assert_eq!( + analyzer.buffer_saturation_drop_count(), + drops_before + 1, + "full-drop (buffer at MAX_BUF, 1,000 bytes delivered): \ + buffer_saturation_drops must increment by exactly 1 \ + (drops_before={drops_before})" + ); + } + + // ── VP-040 Sub-B (no-drop boundary) ────────────────────────────────────── + + /// VP-040 Sub-B (unit): when delivered data fits within the remaining buffer + /// capacity, `buffer_saturation_drops` is NOT incremented. + /// + /// Covers EC-003 (data well within capacity) and EC-005 (exact-fit boundary: + /// `data.len() == remaining`; condition is strictly `>`, so no drop). + /// + /// Setup: fresh analyzer; deliver a small record (100 bytes) that fits in + /// an empty MAX_BUF buffer. Remaining = 65,536; 100 < 65,536 → no drop. + /// + /// EC-005 sub-check: deliver exactly MAX_BUF bytes to an empty buffer. + /// `data.len() == remaining == MAX_BUF`; `MAX_BUF > MAX_BUF` is false; + /// counter must NOT increment. + /// + /// Non-tautology: if the drop condition used `>=` instead of `>`, the EC-005 + /// exact-fit delivery would incorrectly increment the counter and the + /// assertion `drops_after == drops_before` would fail. + /// + /// Traces to: BC-2.07.043 v1.3 Invariant 5; EC-003, EC-005; AC-146-003. + // DF-AC-TEST-NAME-SYNC-001: canonical name verbatim per VP-040 table. + #[allow(non_snake_case)] + #[test] + fn test_BC_2_07_043_no_drop_no_counter() { + let fk = make_test_flow_key(62); + let mut analyzer = TlsAnalyzer::new(); + + let drops_before = analyzer.buffer_saturation_drop_count(); + + // EC-003: small record well within capacity. + let small = vec![0x00u8; 100]; + analyzer.on_data(&fk, Direction::ClientToServer, &small, 0, 0); + + assert_eq!( + analyzer.buffer_saturation_drop_count(), + drops_before, + "no-drop (100 bytes to empty buffer): buffer_saturation_drops must \ + NOT change (drops_before={drops_before})" + ); + + // EC-005: exact-fit — deliver exactly MAX_BUF bytes to a fresh flow. + // remaining = MAX_BUF (fresh); data.len() == MAX_BUF; strictly > is false. + let fk2 = make_test_flow_key(63); + let drops_before_exact = analyzer.buffer_saturation_drop_count(); + let exact_fit = vec![0x00u8; MAX_BUF]; + analyzer.on_data(&fk2, Direction::ClientToServer, &exact_fit, 0, 0); + + assert_eq!( + analyzer.buffer_saturation_drop_count(), + drops_before_exact, + "exact-fit (MAX_BUF bytes to empty buffer): drop condition is strictly >, \ + so counter must NOT change (drops_before_exact={drops_before_exact})" + ); + } + + // ── VP-040 Sub-C (counter persists across flow close) ──────────────────── + + /// VP-040 Sub-C (unit): `buffer_saturation_drops` is an aggregate counter on + /// `TlsAnalyzer` — NOT on `TlsFlowState` — and is NOT reset when + /// `on_flow_close` is called. + /// + /// Sequence: snapshot `drops_before`; trigger one drop; assert `drops_before + 1`; + /// call `on_flow_close`; assert counter still equals `drops_before + 1`. + /// + /// Non-tautology: if the counter were placed on `TlsFlowState` and zeroed on + /// flow close, the final assertion would fail (counter would revert to 0 or + /// be unreachable after `flows.remove`). + /// + /// Traces to: BC-2.07.043 v1.3 Postcondition 5, Invariant 2; AC-146-004. + // DF-AC-TEST-NAME-SYNC-001: canonical name verbatim per VP-040 table. + #[allow(non_snake_case)] + #[test] + fn test_BC_2_07_043_counter_persists_across_flows() { + let fk = make_test_flow_key(64); + let mut analyzer = TlsAnalyzer::new(); + + // Snapshot before drop. + let drops_before = analyzer.buffer_saturation_drop_count(); + + // Trigger exactly one drop: 65,537 bytes to empty buffer. + let data = vec![0x00u8; MAX_BUF + 1]; + analyzer.on_data(&fk, Direction::ClientToServer, &data, 0, 0); + + let drops_after_drop = analyzer.buffer_saturation_drop_count(); + assert_eq!( + drops_after_drop, + drops_before + 1, + "counter must increment after drop \ + (drops_before={drops_before}, drops_after_drop={drops_after_drop})" + ); + + // Close the flow. + analyzer.on_flow_close(&fk, CloseReason::Fin); + + // Counter must be unchanged by flow close. + assert_eq!( + analyzer.buffer_saturation_drop_count(), + drops_before + 1, + "buffer_saturation_drops must NOT reset on on_flow_close; \ + aggregate counter persists on TlsAnalyzer, not TlsFlowState \ + (drops_before={drops_before})" + ); + } + + // ── VP-040 Sub-D (summarize value-equality) ─────────────────────────────── + + /// VP-040 Sub-D (unit): `summarize()` exposes `"buffer_saturation_drops"` in + /// the `detail` map with value-equality to the current counter. + /// + /// After triggering 1 drop, `detail["buffer_saturation_drops"].as_u64()` + /// must equal `drops_before + 1` (value-equality, not mere key presence). + /// + /// Also verifies EC-008: the key is present even when the count is 0 (checked + /// on a fresh analyzer before the drop). + /// + /// Non-tautology: if `summarize()` omits the `"buffer_saturation_drops"` key + /// or inserts a wrong value, the `.expect()` or the `as_u64()` assertion fails. + /// + /// Traces to: BC-2.07.043 v1.3 Postcondition 4; EC-008; AC-146-005. + // DF-AC-TEST-NAME-SYNC-001: canonical name verbatim per VP-040 table. + #[allow(non_snake_case)] + #[test] + fn test_BC_2_07_043_summarize_value_equals_drop_count() { + let fk = make_test_flow_key(65); + let mut analyzer = TlsAnalyzer::new(); + + // EC-008 sub-check: key present even when counter is 0. + let summary_zero = analyzer.summarize(); + let zero_val = summary_zero.detail.get("buffer_saturation_drops").expect( + "summarize() must contain 'buffer_saturation_drops' key even when count==0 (EC-008)", + ); + assert_eq!( + zero_val.as_u64(), + Some(0), + "summarize() detail['buffer_saturation_drops'] must equal 0 on fresh \ + analyzer; got: {zero_val:?}" + ); + + // Trigger exactly 1 drop. + let drops_before = analyzer.buffer_saturation_drop_count(); + let data = vec![0x00u8; MAX_BUF + 1]; + analyzer.on_data(&fk, Direction::ClientToServer, &data, 0, 0); + + let summary = analyzer.summarize(); + let drop_val = summary + .detail + .get("buffer_saturation_drops") + .expect("summarize() must contain 'buffer_saturation_drops' key after a drop"); + + assert_eq!( + drop_val.as_u64(), + Some(drops_before + 1), + "summarize() detail['buffer_saturation_drops'] must equal drops_before+1={}; \ + got: {drop_val:?}", + drops_before + 1 + ); + } + + // ── VP-040 Sub-E (both directions increment the same counter) ──────────── + + /// VP-040 Sub-E (unit): one `ClientToServer` drop and one `ServerToClient` + /// drop each increment the SAME aggregate `buffer_saturation_drops` counter, + /// so after both drops the counter equals `drops_initial + 2`. + /// + /// Setup: use `fill_buf_for_testing` to park the S2C buffer at MAX_BUF for + /// the S2C drop (full-drop path). The C2S drop uses the no-seam partial-drop + /// path (65,537 bytes to an empty C2S buffer). + /// + /// Non-tautology: if the two directions incremented separate counters or if + /// either direction failed to increment at all, `drops_final == drops_initial + 2` + /// would fail. + /// + /// Traces to: BC-2.07.043 v1.3 Postcondition 3; EC-007; AC-146-006. + // DF-AC-TEST-NAME-SYNC-001: canonical name verbatim per VP-040 table. + #[allow(non_snake_case)] + #[test] + fn test_BC_2_07_043_both_directions_increment_same_counter() { + let fk_c2s = make_test_flow_key(66); + let fk_s2c = make_test_flow_key(67); + let mut analyzer = TlsAnalyzer::new(); + + let drops_initial = analyzer.buffer_saturation_drop_count(); + + // C2S drop: 65,537 bytes to empty C2S buffer (partial-drop, no seam needed). + let data_c2s = vec![0x00u8; MAX_BUF + 1]; + analyzer.on_data(&fk_c2s, Direction::ClientToServer, &data_c2s, 0, 0); + + assert_eq!( + analyzer.buffer_saturation_drop_count(), + drops_initial + 1, + "after C2S drop: counter must be drops_initial+1={} \ + (drops_initial={drops_initial})", + drops_initial + 1 + ); + + // S2C drop: park the S2C buffer at MAX_BUF via seam, then deliver 1 byte. + analyzer.fill_buf_for_testing(&fk_s2c, Direction::ServerToClient, MAX_BUF); + let data_s2c = vec![0x00u8; 1]; + analyzer.on_data(&fk_s2c, Direction::ServerToClient, &data_s2c, 0, 0); + + assert_eq!( + analyzer.buffer_saturation_drop_count(), + drops_initial + 2, + "after C2S+S2C drops: counter must be drops_initial+2={} \ + (drops_initial={drops_initial})", + drops_initial + 2 + ); + } + + // ── EC-C1: partial-drop at the 65,535-byte fill boundary ───────────────── + + /// EC-C1 (unit): filling the buffer to 65,535 bytes (1 byte of remaining + /// capacity), then delivering 2 bytes, triggers exactly one drop-event + /// increment and leaves `parse_error_count` unchanged. + /// + /// Setup: `fill_buf_for_testing` parks the C2S buffer at 65,535 bytes + /// (remaining == 1). Action: `on_data` with 2 bytes. Since 2 > 1, the + /// tail-drop condition (`data.len() > remaining`) fires; 1 byte is + /// appended, 1 byte is dropped. + /// + /// Non-tautology: EC-C1 pins the partial-drop boundary — the test fails if + /// drop detection is broken such that the partial-drop event is not counted + /// (e.g., `did_drop` not set, or the saturation counter not incremented), or + /// if the counter fires the wrong number of times. The `to_copy < + /// data.len()` mutant form is distinguished by the full-drop tests where + /// `remaining == 0` (see `test_BC_2_07_043_buffer_saturation_full_drop` and + /// `test_BC_2_07_043_full_buffer_empty_data_no_count`), not by this test. + /// + /// Traces to: BC-2.07.043 v1.3 EC-C1; AC-146-002 (partial-drop boundary). + #[allow(non_snake_case)] + #[test] + fn test_BC_2_07_043_partial_drop_boundary() { + let fk = make_test_flow_key(68); + let mut analyzer = TlsAnalyzer::new(); + + // Park C2S buffer at 65,535 bytes (1 byte of remaining capacity). + analyzer.fill_buf_for_testing(&fk, Direction::ClientToServer, MAX_BUF - 1); + + let drops_before = analyzer.buffer_saturation_drop_count(); + let parse_errors_before = analyzer.parse_error_count(); + + // Deliver 2 bytes: remaining == 1, so 2 > 1 → drop fires, 1 byte appended. + let data = vec![0x00u8; 2]; + analyzer.on_data(&fk, Direction::ClientToServer, &data, 0, 0); + + assert_eq!( + analyzer.buffer_saturation_drop_count(), + drops_before + 1, + "EC-C1 partial boundary (65,535-fill + 2 bytes): buffer_saturation_drops \ + must increment by exactly 1 (drops_before={drops_before})" + ); + assert_eq!( + analyzer.parse_error_count(), + parse_errors_before, + "EC-C1 partial boundary: parse_error_count must NOT change on buffer \ + saturation (parse_errors_before={parse_errors_before})" + ); + } + + // ── EC-C3: full buffer + empty data slice — no increment ───────────────── + + /// EC-C3 (unit): filling the buffer to MAX_BUF (65,536 bytes), then + /// delivering an empty slice `&[]`, does NOT increment + /// `buffer_saturation_drops`. + /// + /// Setup: `fill_buf_for_testing` parks the C2S buffer at MAX_BUF bytes + /// (remaining == 0). Action: `on_data` with 0 bytes. The drop condition + /// is `data.len() > remaining` = `0 > 0` = false, so no increment occurs. + /// + /// Non-tautology: if the strict-`>` boundary were relaxed to `>=` (a + /// plausible mutation), then `0 >= 0` would evaluate to true, the counter + /// would increment, and the assertion `drops_after == drops_before` would + /// fail. + /// + /// Traces to: BC-2.07.043 v1.3 EC-C3; AC-146-002 (strict-> predicate). + #[allow(non_snake_case)] + #[test] + fn test_BC_2_07_043_full_buffer_empty_data_no_count() { + let fk = make_test_flow_key(69); + let mut analyzer = TlsAnalyzer::new(); + + // Park C2S buffer at MAX_BUF (remaining == 0). + analyzer.fill_buf_for_testing(&fk, Direction::ClientToServer, MAX_BUF); + + let drops_before = analyzer.buffer_saturation_drop_count(); + + // Deliver 0 bytes: 0 > 0 is false — drop must NOT fire. + analyzer.on_data(&fk, Direction::ClientToServer, &[], 0, 0); + + assert_eq!( + analyzer.buffer_saturation_drop_count(), + drops_before, + "EC-C3 empty-data / full-buffer: buffer_saturation_drops must NOT \ + increment when data.len()==0 (drops_before={drops_before})" + ); + } +}