1use std::collections::{HashMap, VecDeque};
8use std::time::{Duration, Instant};
9
10use super::format::{chrono_now, status_bucket_index};
11
12pub const MAX_BODY_EXCERPT: usize = 1024;
16
17pub const REQUEST_RING: usize = 5000;
21
22pub const SPARK_WINDOW_SECS: usize = 60;
24
25pub const LATENCY_RING: usize = 1024;
28
29pub const MAX_FILTER_LEN: usize = 80;
32
33const MAX_TUI_HOSTS: usize = 4096;
37
38pub const TOAST_TTL: Duration = Duration::from_millis(2400);
40
41#[derive(Debug, Clone)]
43#[allow(clippy::large_enum_variant)]
44pub enum Event {
45 Request {
47 host: String,
48 method: String,
49 path: String,
50 status: u16,
51 bypassed: bool,
52 blocked: bool,
53 techniques: String,
54 tls_profile: Option<String>,
55 body_padded: bool,
56 upstream_latency_ms: u64,
57 waf_name: Option<String>,
58 req_headers: Vec<(String, String)>,
60 req_body_excerpt: Vec<u8>,
62 req_headers_pre: Vec<(String, String)>,
66 req_body_pre_excerpt: Vec<u8>,
68 resp_headers: Vec<(String, String)>,
69 resp_body_excerpt: Vec<u8>,
70 resp_body_total: u64,
71 attempts: u32,
72 },
73 ResetCounters,
76}
77
78#[derive(Debug, Clone)]
80pub struct RequestRecord {
81 pub timestamp: String,
82 pub host: String,
83 pub method: String,
84 pub path: String,
85 pub status: u16,
86 pub bypassed: bool,
87 pub blocked: bool,
88 pub techniques: String,
89 pub tls_profile: Option<String>,
90 pub body_padded: bool,
91 pub upstream_latency_ms: u64,
92 pub waf_name: Option<String>,
93 pub req_headers: Vec<(String, String)>,
94 pub req_body_excerpt: Vec<u8>,
95 pub req_headers_pre: Vec<(String, String)>,
96 pub req_body_pre_excerpt: Vec<u8>,
97 pub resp_headers: Vec<(String, String)>,
98 pub resp_body_excerpt: Vec<u8>,
99 pub resp_body_total: u64,
100 pub attempts: u32,
101}
102
103impl RequestRecord {
104 pub fn outcome(&self) -> &'static str {
105 if self.bypassed {
106 "BYPASS"
107 } else if self.blocked {
108 "BLOCK"
109 } else {
110 "PASS"
111 }
112 }
113
114 pub fn technique_keys(&self) -> impl Iterator<Item = &str> {
118 self.techniques
119 .split(',')
120 .map(str::trim)
121 .filter(|s| !s.is_empty())
122 }
123}
124
125#[derive(Default, Debug, Clone)]
126pub struct HostStats {
127 pub sent: u64,
128 pub blocked: u64,
129 pub bypassed: u64,
130 pub top_technique: String,
131 pub waf_name: Option<String>,
132}
133
134#[derive(Default, Debug, Clone)]
135pub struct TlsStats {
136 pub counts: HashMap<String, u64>,
137}
138
139impl TlsStats {
140 pub fn record(&mut self, profile: &str) {
141 *self.counts.entry(profile.to_string()).or_insert(0) += 1;
142 }
143 pub fn total(&self) -> u64 {
144 self.counts.values().sum()
145 }
146}
147
148#[derive(Default, Debug, Clone)]
150pub struct TechStats {
151 pub tried: u64,
152 pub bypassed: u64,
153 pub last_bypass_unix_secs: u64,
154}
155
156impl TechStats {
157 pub fn bypass_rate(&self) -> f64 {
158 if self.tried == 0 {
159 0.0
160 } else {
161 #[allow(clippy::cast_precision_loss)]
162 let r = self.bypassed as f64 / self.tried as f64;
163 r
164 }
165 }
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
170pub enum Tab {
171 #[default]
172 Flow,
173 Overview,
174 Hosts,
175 Techniques,
176 Intercept,
177}
178
179impl Tab {
180 pub const ORDER: [Tab; 5] = [
181 Self::Flow,
182 Self::Overview,
183 Self::Hosts,
184 Self::Techniques,
185 Self::Intercept,
186 ];
187
188 pub fn next(self) -> Self {
189 match self {
190 Self::Flow => Self::Overview,
191 Self::Overview => Self::Hosts,
192 Self::Hosts => Self::Techniques,
193 Self::Techniques => Self::Intercept,
194 Self::Intercept => Self::Flow,
195 }
196 }
197 pub fn label(self) -> &'static str {
198 match self {
199 Self::Flow => "Flow",
200 Self::Overview => "Overview",
201 Self::Hosts => "Hosts",
202 Self::Techniques => "Techniques",
203 Self::Intercept => "Intercept",
204 }
205 }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
210pub enum OutcomeFilter {
211 #[default]
212 All,
213 BypassOnly,
214 BlockOnly,
215 PassOnly,
216}
217
218impl OutcomeFilter {
219 pub fn next(self) -> Self {
220 match self {
221 Self::All => Self::BypassOnly,
222 Self::BypassOnly => Self::BlockOnly,
223 Self::BlockOnly => Self::PassOnly,
224 Self::PassOnly => Self::All,
225 }
226 }
227
228 pub fn label(self) -> &'static str {
229 match self {
230 Self::All => "ALL",
231 Self::BypassOnly => "BYPASS",
232 Self::BlockOnly => "BLOCK",
233 Self::PassOnly => "PASS",
234 }
235 }
236
237 pub fn matches(self, rec: &RequestRecord) -> bool {
238 match self {
239 Self::All => true,
240 Self::BypassOnly => rec.bypassed,
241 Self::BlockOnly => rec.blocked,
242 Self::PassOnly => !rec.bypassed && !rec.blocked,
243 }
244 }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Default)]
249pub enum InputMode {
250 #[default]
251 Normal,
252 FilterEdit,
254}
255
256#[derive(Debug, Clone, Copy)]
258pub enum ToastKind {
259 Info,
260 Ok,
261 Warn,
262 Err,
263}
264
265#[derive(Debug, Clone)]
266pub struct Toast {
267 pub message: String,
268 pub kind: ToastKind,
269 pub expires: Instant,
270}
271
272impl Toast {
273 pub fn new(message: impl Into<String>, kind: ToastKind) -> Self {
274 Self {
275 message: message.into(),
276 kind,
277 expires: Instant::now() + TOAST_TTL,
278 }
279 }
280}
281
282#[derive(Default, Clone, Copy)]
284pub struct SecBucket {
285 pub requests: u64,
286 pub bypasses: u64,
287}
288
289#[derive(Default)]
290pub struct State {
291 pub started: Option<Instant>,
292 pub total: u64,
293 pub bypassed: u64,
294 pub blocked: u64,
295 pub errors: u64,
296 pub padded: u64,
297 pub latency_sum_ms: u64,
298 pub latency_samples: VecDeque<u64>,
299 pub status_buckets: [u64; 6],
300 pub hosts: HashMap<String, HostStats>,
301 pub tls: TlsStats,
302 pub recent: VecDeque<RequestRecord>,
303 pub selected: Option<usize>,
306 pub inspect: bool,
308 pub detail_scroll: u16,
310 pub tab: Tab,
312 pub spark: VecDeque<SecBucket>,
313 pub spark_current_sec: u64,
314 pub waf_seen: HashMap<String, u64>,
315 pub attempts_sum: u64,
316 pub tech_stats: HashMap<String, TechStats>,
318 pub outcome_filter: OutcomeFilter,
320 pub input_mode: InputMode,
322 pub filter_query: String,
324 pub follow: bool,
326 pub toast: Option<Toast>,
328 pub yank_seq: u64,
330 pub intercept_selected: Option<u64>,
334}
335
336impl State {
337 pub fn new() -> Self {
338 Self {
339 started: Some(Instant::now()),
340 tab: Tab::Flow,
341 follow: true,
342 ..Self::default()
343 }
344 }
345
346 pub fn record(&mut self, ev: &Event) {
347 match ev {
348 Event::Request {
349 host,
350 method,
351 path,
352 status,
353 bypassed,
354 blocked,
355 techniques,
356 tls_profile,
357 body_padded,
358 upstream_latency_ms,
359 waf_name,
360 req_headers,
361 req_body_excerpt,
362 req_headers_pre,
363 req_body_pre_excerpt,
364 resp_headers,
365 resp_body_excerpt,
366 resp_body_total,
367 attempts,
368 } => {
369 self.total += 1;
370 if *bypassed {
371 self.bypassed += 1;
372 }
373 if *blocked {
374 self.blocked += 1;
375 }
376 if *status >= 500 {
377 self.errors += 1;
378 }
379 if *body_padded {
380 self.padded += 1;
381 }
382 self.latency_sum_ms = self.latency_sum_ms.saturating_add(*upstream_latency_ms);
383 self.attempts_sum = self.attempts_sum.saturating_add(u64::from(*attempts));
384 self.push_latency_sample(*upstream_latency_ms);
385 self.status_buckets[status_bucket_index(*status)] += 1;
386
387 let hs = self.hosts.entry(host.clone()).or_default();
388 hs.sent += 1;
389 if *blocked {
390 hs.blocked += 1;
391 }
392 if *bypassed {
393 hs.bypassed += 1;
394 }
395 if !techniques.is_empty() {
396 hs.top_technique.clone_from(techniques);
397 }
398 if let Some(w) = waf_name {
399 if hs.waf_name.is_none() {
400 *self.waf_seen.entry(w.clone()).or_insert(0) += 1;
401 }
402 hs.waf_name = Some(w.clone());
403 }
404 if self.hosts.len() > MAX_TUI_HOSTS
408 && let Some(evict) = self
409 .hosts
410 .iter()
411 .min_by_key(|(_, v)| v.sent)
412 .map(|(k, _)| k.clone())
413 {
414 self.hosts.remove(&evict);
415 }
416
417 if let Some(p) = tls_profile {
418 self.tls.record(p);
419 }
420
421 self.bump_spark(*bypassed);
422 self.tally_techniques(techniques, *bypassed);
423
424 let rec = RequestRecord {
425 timestamp: chrono_now(),
426 host: host.clone(),
427 method: method.clone(),
428 path: path.clone(),
429 status: *status,
430 bypassed: *bypassed,
431 blocked: *blocked,
432 techniques: techniques.clone(),
433 tls_profile: tls_profile.clone(),
434 body_padded: *body_padded,
435 upstream_latency_ms: *upstream_latency_ms,
436 waf_name: waf_name.clone(),
437 req_headers: req_headers.clone(),
438 req_body_excerpt: req_body_excerpt.clone(),
439 req_headers_pre: req_headers_pre.clone(),
440 req_body_pre_excerpt: req_body_pre_excerpt.clone(),
441 resp_headers: resp_headers.clone(),
442 resp_body_excerpt: resp_body_excerpt.clone(),
443 resp_body_total: *resp_body_total,
444 attempts: *attempts,
445 };
446 if self.recent.len() == REQUEST_RING {
447 self.recent.pop_front();
448 if let Some(i) = self.selected.as_mut() {
449 *i = i.saturating_sub(1);
450 }
451 }
452 self.recent.push_back(rec);
453
454 if self.follow && self.selected.is_some() {
459 self.selected = Some(self.recent.len() - 1);
460 }
461 }
462 Event::ResetCounters => {
463 let started = self.started;
464 let tab = self.tab;
465 let outcome_filter = self.outcome_filter;
466 let filter_query = std::mem::take(&mut self.filter_query);
467 let follow = self.follow;
468 let yank_seq = self.yank_seq;
469 *self = State::default();
470 self.started = started;
471 self.tab = tab;
472 self.outcome_filter = outcome_filter;
473 self.filter_query = filter_query;
474 self.follow = follow;
475 self.yank_seq = yank_seq;
476 }
477 }
478 }
479
480 fn push_latency_sample(&mut self, ms: u64) {
481 if self.latency_samples.len() == LATENCY_RING {
482 self.latency_samples.pop_front();
483 }
484 self.latency_samples.push_back(ms);
485 }
486
487 fn tally_techniques(&mut self, csv: &str, bypassed: bool) {
488 for key in csv.split(',').map(str::trim).filter(|s| !s.is_empty()) {
489 let entry = self.tech_stats.entry(key.to_string()).or_default();
490 entry.tried += 1;
491 if bypassed {
492 entry.bypassed += 1;
493 entry.last_bypass_unix_secs = std::time::SystemTime::now()
494 .duration_since(std::time::UNIX_EPOCH)
495 .unwrap_or_default()
496 .as_secs();
497 }
498 }
499 }
500
501 fn bump_spark(&mut self, bypassed: bool) {
502 let now = std::time::SystemTime::now()
503 .duration_since(std::time::UNIX_EPOCH)
504 .unwrap_or_default()
505 .as_secs();
506 if self.spark_current_sec != now {
507 self.spark_current_sec = now;
508 self.spark.push_back(SecBucket::default());
509 while self.spark.len() > SPARK_WINDOW_SECS {
510 self.spark.pop_front();
511 }
512 }
513 if let Some(b) = self.spark.back_mut() {
514 b.requests += 1;
515 if bypassed {
516 b.bypasses += 1;
517 }
518 }
519 }
520
521 pub fn uptime(&self) -> Duration {
522 self.started
523 .map_or_else(|| Duration::from_secs(0), |s| s.elapsed())
524 }
525
526 pub fn avg_latency_ms(&self) -> u64 {
527 self.latency_sum_ms.checked_div(self.total).unwrap_or(0)
528 }
529
530 pub fn latency_percentile(&self, p: f64) -> u64 {
534 if self.latency_samples.is_empty() {
535 return 0;
536 }
537 let mut v: Vec<u64> = self.latency_samples.iter().copied().collect();
538 v.sort_unstable();
539 let p = p.clamp(0.0, 1.0);
540 #[allow(
543 clippy::cast_possible_truncation,
544 clippy::cast_sign_loss,
545 clippy::cast_precision_loss
546 )]
547 let idx = ((v.len() as f64 - 1.0) * p).floor() as usize;
548 v[idx]
549 }
550
551 pub fn bypass_rate_pct(&self) -> f64 {
552 if self.total == 0 {
553 return 0.0;
554 }
555 #[allow(clippy::cast_precision_loss)]
556 let r = (self.bypassed as f64 / self.total as f64) * 100.0;
557 r
558 }
559
560 pub fn rps_recent(&self) -> f64 {
561 let n = self.spark.len().min(5);
562 if n == 0 {
563 return 0.0;
564 }
565 #[allow(clippy::cast_precision_loss)]
566 let sum: f64 = self
567 .spark
568 .iter()
569 .rev()
570 .take(n)
571 .map(|b| b.requests as f64)
572 .sum();
573 sum / (n as f64)
574 }
575
576 pub fn top_hosts(&self, n: usize) -> Vec<(&String, &HostStats)> {
577 let mut v: Vec<_> = self.hosts.iter().collect();
578 v.sort_by_key(|b| std::cmp::Reverse(b.1.sent));
579 v.truncate(n);
580 v
581 }
582
583 pub fn visible_indices(&self) -> Vec<usize> {
586 let q = self.filter_query.to_ascii_lowercase();
587 let q = q.trim();
588 self.recent
589 .iter()
590 .enumerate()
591 .filter(|(_, r)| self.outcome_filter.matches(r))
592 .filter(|(_, r)| {
593 if q.is_empty() {
594 true
595 } else {
596 r.host.to_ascii_lowercase().contains(q)
597 || r.path.to_ascii_lowercase().contains(q)
598 || r.method.to_ascii_lowercase().contains(q)
599 || r.techniques.to_ascii_lowercase().contains(q)
600 || r.waf_name
601 .as_deref()
602 .is_some_and(|w| w.to_ascii_lowercase().contains(q))
603 }
604 })
605 .map(|(i, _)| i)
606 .collect()
607 }
608
609 pub fn select_offset(&mut self, delta: i64) {
613 let visible = self.visible_indices();
614 if visible.is_empty() {
615 self.selected = None;
616 return;
617 }
618 let cur_visible = self
619 .selected
620 .and_then(|i| visible.iter().position(|&v| v == i))
621 .unwrap_or(visible.len().saturating_sub(1));
622 #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
623 let new_visible =
624 (cur_visible as i64 + delta).clamp(0, (visible.len() - 1) as i64) as usize;
625 self.selected = Some(visible[new_visible]);
626 if delta != 0 {
629 self.follow = false;
630 }
631 }
632
633 pub fn select_first(&mut self) {
634 let visible = self.visible_indices();
635 if let Some(&first) = visible.first() {
636 self.selected = Some(first);
637 self.follow = false;
638 } else {
639 self.selected = None;
640 }
641 }
642
643 pub fn select_last(&mut self) {
644 let visible = self.visible_indices();
645 if let Some(&last) = visible.last() {
646 self.selected = Some(last);
647 } else {
648 self.selected = None;
649 }
650 }
651
652 pub fn toggle_follow(&mut self) {
653 self.follow = !self.follow;
654 if self.follow {
655 let visible = self.visible_indices();
657 if let Some(&last) = visible.last() {
658 self.selected = Some(last);
659 }
660 }
661 }
662
663 pub fn cycle_outcome_filter(&mut self) {
664 self.outcome_filter = self.outcome_filter.next();
665 let visible = self.visible_indices();
667 if let Some(sel) = self.selected
668 && !visible.contains(&sel)
669 {
670 self.selected = visible.last().copied();
671 }
672 }
673
674 pub fn enter_filter_edit(&mut self) {
675 self.input_mode = InputMode::FilterEdit;
676 }
677
678 pub fn cancel_filter_edit(&mut self) {
679 self.input_mode = InputMode::Normal;
680 self.filter_query.clear();
681 let visible = self.visible_indices();
682 if let Some(sel) = self.selected
683 && !visible.contains(&sel)
684 {
685 self.selected = visible.last().copied();
686 }
687 }
688
689 pub fn commit_filter_edit(&mut self) {
690 self.input_mode = InputMode::Normal;
691 let visible = self.visible_indices();
692 if let Some(sel) = self.selected {
693 if !visible.contains(&sel) {
694 self.selected = visible.last().copied();
695 }
696 } else {
697 self.selected = visible.last().copied();
698 }
699 }
700
701 pub fn filter_push(&mut self, c: char) {
702 if self.filter_query.chars().count() < MAX_FILTER_LEN {
703 self.filter_query.push(c);
704 }
705 }
706
707 pub fn filter_backspace(&mut self) {
708 self.filter_query.pop();
709 }
710
711 pub fn set_toast(&mut self, msg: impl Into<String>, kind: ToastKind) {
712 self.toast = Some(Toast::new(msg, kind));
713 }
714
715 pub fn tick_toast(&mut self) {
717 if let Some(t) = &self.toast
718 && Instant::now() >= t.expires
719 {
720 self.toast = None;
721 }
722 }
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728
729 fn req(host: &str, status: u16, bypassed: bool, padded: bool, profile: Option<&str>) -> Event {
730 Event::Request {
731 host: host.to_string(),
732 method: "GET".into(),
733 path: "/".into(),
734 status,
735 bypassed,
736 blocked: !bypassed && status == 403,
737 techniques: "encoding:UrlEncode".into(),
738 tls_profile: profile.map(std::string::ToString::to_string),
739 body_padded: padded,
740 upstream_latency_ms: 50,
741 waf_name: None,
742 req_headers: vec![],
743 req_body_excerpt: vec![],
744 req_headers_pre: vec![],
745 req_body_pre_excerpt: vec![],
746 resp_headers: vec![],
747 resp_body_excerpt: vec![],
748 resp_body_total: 0,
749 attempts: 0,
750 }
751 }
752
753 fn req_with(host: &str, path: &str, status: u16, bypassed: bool, techniques: &str) -> Event {
754 let mut e = req(host, status, bypassed, false, None);
755 if let Event::Request {
756 path: p,
757 techniques: t,
758 ..
759 } = &mut e
760 {
761 *p = path.into();
762 *t = techniques.into();
763 }
764 e
765 }
766
767 fn req_with_waf(host: &str, waf: &str) -> Event {
768 let mut e = req(host, 403, false, false, None);
769 if let Event::Request { waf_name, .. } = &mut e {
770 *waf_name = Some(waf.to_string());
771 }
772 e
773 }
774
775 #[test]
776 fn state_counts_bypass_block_padding_and_status_buckets() {
777 let mut s = State::new();
778 s.record(&req("a.com", 200, true, true, Some("chrome131")));
779 s.record(&req("a.com", 403, false, true, Some("firefox133")));
780 s.record(&req("b.com", 500, false, false, None));
781 assert_eq!(s.total, 3);
782 assert_eq!(s.bypassed, 1);
783 assert_eq!(s.blocked, 1);
784 assert_eq!(s.errors, 1);
785 assert_eq!(s.padded, 2);
786 assert_eq!(s.status_buckets[1], 1); assert_eq!(s.status_buckets[3], 1); assert_eq!(s.status_buckets[4], 1); }
790
791 #[test]
792 fn latency_percentiles_track_distribution() {
793 let mut s = State::new();
794 for ms in [10u64, 20, 30, 40, 50, 60, 70, 80, 90, 100] {
795 let mut e = req("h", 200, true, false, None);
796 if let Event::Request {
797 upstream_latency_ms,
798 ..
799 } = &mut e
800 {
801 *upstream_latency_ms = ms;
802 }
803 s.record(&e);
804 }
805 assert_eq!(s.latency_percentile(0.5), 50);
806 assert_eq!(s.latency_percentile(0.9), 90);
807 assert_eq!(s.latency_percentile(1.0), 100);
808 assert_eq!(s.latency_percentile(0.0), 10);
809 }
810
811 #[test]
812 fn latency_ring_capped_at_1024() {
813 let mut s = State::new();
814 for i in 0..(LATENCY_RING + 100) {
815 let mut e = req("h", 200, false, false, None);
816 if let Event::Request {
817 upstream_latency_ms,
818 ..
819 } = &mut e
820 {
821 *upstream_latency_ms = i as u64;
822 }
823 s.record(&e);
824 }
825 assert_eq!(s.latency_samples.len(), LATENCY_RING);
826 assert_eq!(s.latency_samples.front().copied(), Some(100));
828 }
829
830 #[test]
831 fn outcome_filter_cycles_and_filters() {
832 let mut s = State::new();
833 s.record(&req_with("a.com", "/x", 200, true, "encoding:UrlEncode"));
834 s.record(&req_with("a.com", "/y", 403, false, "")); s.record(&req_with("a.com", "/z", 200, false, "")); assert_eq!(s.visible_indices().len(), 3);
837 s.cycle_outcome_filter(); assert_eq!(s.outcome_filter, OutcomeFilter::BypassOnly);
839 assert_eq!(s.visible_indices().len(), 1);
840 s.cycle_outcome_filter(); assert_eq!(s.visible_indices().len(), 1);
842 s.cycle_outcome_filter(); assert_eq!(s.visible_indices().len(), 1);
844 s.cycle_outcome_filter(); assert_eq!(s.visible_indices().len(), 3);
846 }
847
848 #[test]
849 fn filter_query_matches_host_path_method_techniques_waf_case_insensitive() {
850 let mut s = State::new();
851 s.record(&req_with(
852 "api.target.com",
853 "/admin",
854 200,
855 true,
856 "encoding:UrlEncode",
857 ));
858 s.record(&req_with(
859 "static.example.com",
860 "/style.css",
861 200,
862 false,
863 "",
864 ));
865 s.filter_query = "ADMIN".into();
866 let v = s.visible_indices();
867 assert_eq!(v.len(), 1, "filter must be case-insensitive on path");
868 s.filter_query = "url".into();
869 assert_eq!(s.visible_indices().len(), 1);
870 s.filter_query = "static".into();
871 assert_eq!(s.visible_indices().len(), 1);
872 s.filter_query = "nope".into();
873 assert_eq!(s.visible_indices().len(), 0);
874 }
875
876 #[test]
877 fn select_navigation_uses_visible_only() {
878 let mut s = State::new();
879 s.record(&req_with("a.com", "/x", 200, true, "")); s.record(&req_with("a.com", "/y", 403, false, "")); s.record(&req_with("a.com", "/z", 200, true, "")); s.outcome_filter = OutcomeFilter::BypassOnly;
883 s.select_last();
884 assert_eq!(s.selected, Some(2));
886 s.select_offset(-1);
887 assert_eq!(s.selected, Some(0));
889 s.select_offset(-1);
890 assert_eq!(s.selected, Some(0));
892 }
893
894 #[test]
895 fn tech_stats_per_key_tally() {
896 let mut s = State::new();
897 s.record(&req_with(
898 "h",
899 "/",
900 200,
901 true,
902 "encoding:UrlEncode, grammar:cmd",
903 ));
904 s.record(&req_with("h", "/", 200, true, "encoding:UrlEncode"));
905 s.record(&req_with("h", "/", 403, false, "encoding:UrlEncode"));
906 let url = s.tech_stats.get("encoding:UrlEncode").expect("present");
907 assert_eq!(url.tried, 3);
908 assert_eq!(url.bypassed, 2);
909 let cmd = s.tech_stats.get("grammar:cmd").expect("present");
910 assert_eq!(cmd.tried, 1);
911 assert_eq!(cmd.bypassed, 1);
912 }
913
914 #[test]
915 fn waf_seen_increments_once_per_host() {
916 let mut s = State::new();
917 s.record(&req_with_waf("a.com", "Cloudflare"));
918 s.record(&req_with_waf("a.com", "Cloudflare"));
919 s.record(&req_with_waf("b.com", "Cloudflare"));
920 s.record(&req_with_waf("c.com", "ModSecurity"));
921 assert_eq!(s.waf_seen.get("Cloudflare"), Some(&2));
922 assert_eq!(s.waf_seen.get("ModSecurity"), Some(&1));
923 }
924
925 #[test]
926 fn reset_preserves_uptime_tab_outcome_filter_query_follow() {
927 let mut s = State::new();
928 s.tab = Tab::Hosts;
929 s.outcome_filter = OutcomeFilter::BypassOnly;
930 s.filter_query = "admin".into();
931 s.follow = false;
932 let started = s.started;
933 s.record(&req("a", 200, true, true, Some("chrome131")));
934 s.record(&Event::ResetCounters);
935 assert_eq!(s.total, 0);
936 assert_eq!(s.started, started);
937 assert_eq!(s.tab, Tab::Hosts);
938 assert_eq!(s.outcome_filter, OutcomeFilter::BypassOnly);
939 assert_eq!(s.filter_query, "admin");
940 assert!(!s.follow);
941 }
942
943 #[test]
944 fn toggle_follow_jumps_to_newest_visible_when_engaged() {
945 let mut s = State::new();
946 s.record(&req_with("a", "/x", 200, true, ""));
947 s.record(&req_with("a", "/y", 403, false, ""));
948 s.outcome_filter = OutcomeFilter::BypassOnly;
949 s.selected = Some(0);
950 s.follow = false;
951 s.toggle_follow();
952 assert!(s.follow);
953 assert_eq!(s.selected, Some(0));
955 }
956
957 #[test]
958 fn ring_capped_and_selection_decremented() {
959 let mut s = State::new();
960 for i in 0..(REQUEST_RING + 50) {
961 s.record(&req(&format!("h{i}"), 200, true, false, None));
962 }
963 assert_eq!(s.recent.len(), REQUEST_RING);
964 }
965
966 #[test]
967 fn tab_cycles_in_five() {
968 assert_eq!(Tab::Flow.next(), Tab::Overview);
969 assert_eq!(Tab::Overview.next(), Tab::Hosts);
970 assert_eq!(Tab::Hosts.next(), Tab::Techniques);
971 assert_eq!(Tab::Techniques.next(), Tab::Intercept);
972 assert_eq!(Tab::Intercept.next(), Tab::Flow);
973 }
974
975 #[test]
976 fn outcome_filter_cycles_in_four() {
977 assert_eq!(OutcomeFilter::All.next(), OutcomeFilter::BypassOnly);
978 assert_eq!(OutcomeFilter::BypassOnly.next(), OutcomeFilter::BlockOnly);
979 assert_eq!(OutcomeFilter::BlockOnly.next(), OutcomeFilter::PassOnly);
980 assert_eq!(OutcomeFilter::PassOnly.next(), OutcomeFilter::All);
981 }
982
983 #[test]
984 fn filter_push_caps_length() {
985 let mut s = State::new();
986 for _ in 0..(MAX_FILTER_LEN + 50) {
987 s.filter_push('a');
988 }
989 assert_eq!(s.filter_query.chars().count(), MAX_FILTER_LEN);
990 }
991
992 #[test]
993 fn tui_state_evicts_hosts_over_cap() {
994 let mut s = State::new();
995 for i in 0..(MAX_TUI_HOSTS + 50) {
996 s.record(&req(&format!("host-{i:05}.com"), 200, true, false, None));
997 }
998 assert!(
999 s.hosts.len() <= MAX_TUI_HOSTS,
1000 "hosts.len() = {}",
1001 s.hosts.len()
1002 );
1003 }
1004
1005 #[test]
1006 fn tui_state_evicts_lowest_sent_host() {
1007 let mut s = State::new();
1008 for i in 0..MAX_TUI_HOSTS {
1010 let e = req(&format!("host-{i}.com"), 200, true, false, None);
1011 s.record(&e);
1012 if i % 2 == 0 {
1013 s.record(&e.clone());
1014 }
1015 }
1016 let new_host = "overflow-host.com";
1018 s.record(&req(new_host, 200, true, false, None));
1019 assert!(s.hosts.len() <= MAX_TUI_HOSTS);
1020 let mut missing_odd = false;
1023 for i in 1..MAX_TUI_HOSTS {
1024 if i % 2 != 0 && !s.hosts.contains_key(&format!("host-{i}.com")) {
1025 missing_odd = true;
1026 break;
1027 }
1028 }
1029 assert!(missing_odd, "a lowest-sent host must have been evicted");
1030 assert!(
1031 s.hosts.contains_key(new_host),
1032 "newly inserted host must survive"
1033 );
1034 }
1035
1036 #[test]
1037 fn tui_state_keeps_hosts_under_cap() {
1038 let mut s = State::new();
1039 for i in 0..100 {
1040 s.record(&req(&format!("host-{i}.com"), 200, true, false, None));
1041 }
1042 assert_eq!(s.hosts.len(), 100);
1043 }
1044}