Skip to main content

wafrift_proxy/tui/
state.rs

1//! Mutable state for the TUI: counters, request ring, filter mode,
2//! latency samples, per-technique stats, toast queue.
3//!
4//! Pure state + mutation logic — no rendering, no I/O. Render layers
5//! read from `&State`; the event loop drives [`State::record`].
6
7use std::collections::{HashMap, VecDeque};
8use std::time::{Duration, Instant};
9
10use super::format::{chrono_now, status_bucket_index};
11
12/// Maximum bytes of a request OR response body the dashboard keeps
13/// per record. Bigger bodies are truncated by the emitter; the
14/// dashboard only displays what arrived.
15pub const MAX_BODY_EXCERPT: usize = 1024;
16
17/// Request ring capacity. Sized so a few hours of operator-driven
18/// traffic fit without losing the head; bypass-hunting sessions
19/// regularly hit thousands of requests.
20pub const REQUEST_RING: usize = 5000;
21
22/// Sliding window of per-second buckets feeding the sparklines.
23pub const SPARK_WINDOW_SECS: usize = 60;
24
25/// Latency-sample ring. Big enough that p99 over a busy minute is
26/// stable (≈ one sample per request × ~17 rps × 60 s).
27pub const LATENCY_RING: usize = 1024;
28
29/// Cap on filter-query length. Prevents accidental terminal-paste of
30/// a megabyte from melting the redraw loop.
31pub const MAX_FILTER_LEN: usize = 80;
32
33/// Hard cap on distinct hosts tracked by the TUI. Without this a long-
34/// running proxy scanning high-cardinality host sets leaks memory
35/// indefinitely (each entry is small but unbounded).
36const MAX_TUI_HOSTS: usize = 4096;
37
38/// Toast TTL on the header banner.
39pub const TOAST_TTL: Duration = Duration::from_millis(2400);
40
41/// Outbound proxy → dashboard event.
42#[derive(Debug, Clone)]
43#[allow(clippy::large_enum_variant)]
44pub enum Event {
45    /// One finished proxied request.
46    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        /// Outgoing request headers (post-evade — what hit the wire).
59        req_headers: Vec<(String, String)>,
60        /// Outgoing request body excerpt (post-evade).
61        req_body_excerpt: Vec<u8>,
62        /// PRE-evade request headers (what the client sent before
63        /// wafrift mutated them). Empty when the proxy is in
64        /// passthrough mode and no evade ran.
65        req_headers_pre: Vec<(String, String)>,
66        /// PRE-evade request body excerpt (capped at `MAX_BODY_EXCERPT`).
67        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    /// Soft reset of all counters (the `r` keybinding fires this so
74    /// the proxy main loop and the TUI loop share one code path).
75    ResetCounters,
76}
77
78/// Single inspectable record — one proxied request + its response.
79#[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    /// Tokenise the comma-separated techniques string into individual
115    /// keys for the per-technique leaderboard. Empty input yields an
116    /// empty iterator.
117    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/// Tally for a single evasion technique key (e.g. `encoding:UrlEncode`).
149#[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/// Which top-level view is shown.
169#[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/// Outcome filter cycled by the `o` key.
209#[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/// Input mode — drives whether keystrokes are commands or filter text.
248#[derive(Debug, Clone, PartialEq, Eq, Default)]
249pub enum InputMode {
250    #[default]
251    Normal,
252    /// User is typing into the filter buffer.
253    FilterEdit,
254}
255
256/// Toast banner severity for the right-aligned header chip.
257#[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/// Per-second tally bucket that feeds the sparklines.
283#[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    /// Index INTO `recent` for the Flow tab. `None` means "no
304    /// explicit selection — auto-follow newest at the bottom".
305    pub selected: Option<usize>,
306    /// Whether the inspect/detail pane is open in Flow tab.
307    pub inspect: bool,
308    /// Vertical scroll offset within the open detail pane.
309    pub detail_scroll: u16,
310    /// Currently focused tab.
311    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    /// Per-technique tried/bypassed counts; drives the Techniques tab.
317    pub tech_stats: HashMap<String, TechStats>,
318    /// Outcome filter chip state.
319    pub outcome_filter: OutcomeFilter,
320    /// `Normal` for command keys, `FilterEdit` while typing a query.
321    pub input_mode: InputMode,
322    /// Active substring filter query (case-insensitive, host+path).
323    pub filter_query: String,
324    /// Auto-stick to newest record on every event when true.
325    pub follow: bool,
326    /// Ephemeral header banner (e.g. "yanked → /tmp/...").
327    pub toast: Option<Toast>,
328    /// Monotonic counter for `/tmp/wafrift-yank-N.curl` filenames.
329    pub yank_seq: u64,
330    /// Index INTO the latest `intercept::global_store().snapshot()`
331    /// for the Intercept tab. Recomputed each render — selection
332    /// survives across snapshots when possible.
333    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                // Evict the lowest-traffic host when the cap is exceeded.
405                // This keeps the working set bounded without dropping
406                // high-value targets from the dashboard.
407                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                // Auto-follow: when in follow mode AND nothing is
455                // selected, the list naturally tails. When something
456                // IS selected and follow is on, stick selection to
457                // the newest entry so the operator sees live action.
458                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    /// Compute a percentile (e.g. `0.95`) from the latency-sample ring.
531    /// Returns 0 when the ring is empty. Sorts a copy on every call —
532    /// fine at dashboard refresh rate (≤7 Hz) for a 1024-entry ring.
533    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        // "Nearest rank" with floor — matches NIST C=1 convention for
541        // common percentiles: p50 of [10..100 by 10] = 50, not 60.
542        #[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    /// Return the indices of `recent` that pass the active filter
584    /// query and outcome filter, in chronological order.
585    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    /// Move the selection by `delta` rows within the *visible* list
610    /// (filter-aware). Selection is stored as an index into `recent`
611    /// so external code (detail pane, yank) can deref directly.
612    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        // any explicit navigation drops auto-follow — operator wants to
627        // pin a row.
628        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            // jump back to newest visible row when re-engaging follow
656            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        // Clamp selection so we don't dangle on a now-invisible row.
666        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    /// Drop the toast if it's expired. Called every redraw tick.
716    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); // 200
787        assert_eq!(s.status_buckets[3], 1); // 403
788        assert_eq!(s.status_buckets[4], 1); // 500
789    }
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        // oldest 100 values must have been evicted
827        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, "")); // block (403, not bypassed)
835        s.record(&req_with("a.com", "/z", 200, false, "")); // pass
836        assert_eq!(s.visible_indices().len(), 3);
837        s.cycle_outcome_filter(); // BypassOnly
838        assert_eq!(s.outcome_filter, OutcomeFilter::BypassOnly);
839        assert_eq!(s.visible_indices().len(), 1);
840        s.cycle_outcome_filter(); // BlockOnly
841        assert_eq!(s.visible_indices().len(), 1);
842        s.cycle_outcome_filter(); // PassOnly
843        assert_eq!(s.visible_indices().len(), 1);
844        s.cycle_outcome_filter(); // back to All
845        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, "")); // bypass
880        s.record(&req_with("a.com", "/y", 403, false, "")); // block
881        s.record(&req_with("a.com", "/z", 200, true, "")); // bypass
882        s.outcome_filter = OutcomeFilter::BypassOnly;
883        s.select_last();
884        // Last bypass is index 2 in `recent`
885        assert_eq!(s.selected, Some(2));
886        s.select_offset(-1);
887        // Previous visible bypass is index 0
888        assert_eq!(s.selected, Some(0));
889        s.select_offset(-1);
890        // clamps at start
891        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        // Only one visible (the bypass at index 0)
954        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        // Fill to cap with varying sent counts.
1009        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        // Overflow by one.
1017        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        // All odd-indexed hosts were recorded once (lowest sent) and
1021        // should be candidates for eviction.
1022        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}