vanguards_rs/
cbtverify.rs

1//! Circuit Build Timeout verification for monitoring circuit construction timing.
2//!
3//! This module monitors circuit build timeouts to detect anomalies in circuit
4//! construction timing that could indicate attacks or network issues.
5//!
6//! # Overview
7//!
8//! The CBT verifier tracks:
9//!
10//! - **Launched circuits**: Circuits that have started building
11//! - **Built circuits**: Circuits that completed successfully
12//! - **Timed out circuits**: Circuits that exceeded the build timeout
13//! - **Hidden service circuits**: Separate tracking for HS circuits
14//!
15//! # Timeout Rate Monitoring
16//!
17//! The module calculates timeout rates to detect anomalies:
18//!
19//! - **Overall timeout rate**: `all_timeout / all_launched`
20//! - **HS timeout rate**: `hs_timeout / hs_launched`
21//!
22//! Elevated HS timeout rates compared to overall rates may indicate
23//! targeted attacks against hidden services.
24//!
25//! # What This Module Does NOT Do
26//!
27//! - **Circuit building**: This module only monitors, not builds circuits
28//! - **Timeout adjustment**: Tor manages its own CBT algorithm
29//! - **Attack mitigation**: This module detects but doesn't prevent attacks
30//!
31//! # See Also
32//!
33//! - [`crate::control`] - Event handling that calls CBT verification
34//! - [`crate::bandguards`] - Related bandwidth monitoring
35//! - [Python vanguards cbtverify](https://github.com/mikeperry-tor/vanguards)
36
37use std::collections::HashMap;
38
39use crate::config::LogLevel;
40use crate::logger::plog;
41
42/// Per-circuit tracking for timeout statistics.
43///
44/// Tracks whether a circuit is a hidden service circuit for separate
45/// timeout rate calculations. This allows comparing HS circuit timeout
46/// rates against overall rates to detect targeted attacks.
47///
48/// # Fields
49///
50/// * `circ_id` - The circuit ID being tracked
51/// * `is_hs` - Whether this is a hidden service circuit
52///
53/// # Example
54///
55/// ```rust
56/// use vanguards_rs::cbtverify::CircuitStat;
57///
58/// let stat = CircuitStat::new("123", true);
59/// assert_eq!(stat.circ_id, "123");
60/// assert!(stat.is_hs);
61/// ```
62///
63/// # See Also
64///
65/// - [`TimeoutStats`] - Container for circuit statistics
66#[derive(Debug, Clone)]
67pub struct CircuitStat {
68    /// The circuit ID.
69    pub circ_id: String,
70    /// Whether this is a hidden service circuit.
71    pub is_hs: bool,
72}
73
74impl CircuitStat {
75    /// Creates a new circuit stat entry.
76    pub fn new(circ_id: &str, is_hs: bool) -> Self {
77        Self {
78            circ_id: circ_id.to_string(),
79            is_hs,
80        }
81    }
82}
83
84/// Circuit build timeout statistics.
85///
86/// Tracks circuit build statistics for all circuits and hidden service
87/// circuits separately, allowing comparison of timeout rates.
88///
89/// # Statistics Tracked
90///
91/// | Statistic | Description |
92/// |-----------|-------------|
93/// | `all_launched` | Total circuits that started building |
94/// | `all_built` | Circuits that completed successfully |
95/// | `all_timeout` | Circuits that timed out |
96/// | `hs_launched` | Hidden service circuits started |
97/// | `hs_built` | HS circuits completed |
98/// | `hs_timeout` | HS circuits that timed out |
99///
100/// # Example
101///
102/// ```rust
103/// use vanguards_rs::cbtverify::TimeoutStats;
104///
105/// let mut stats = TimeoutStats::new();
106///
107/// // Track a circuit launch
108/// stats.add_circuit("123", true);
109/// assert_eq!(stats.all_launched, 1);
110/// assert_eq!(stats.hs_launched, 1);
111///
112/// // Track circuit completion
113/// stats.built_circuit("123");
114/// assert_eq!(stats.all_built, 1);
115/// assert_eq!(stats.hs_built, 1);
116///
117/// // Check timeout rates
118/// assert_eq!(stats.timeout_rate_all(), 0.0);
119/// assert_eq!(stats.timeout_rate_hs(), 0.0);
120/// ```
121///
122/// # CBT Event Handling
123///
124/// The statistics respond to Tor's CBT algorithm events:
125///
126/// - `COMPUTED`: CBT algorithm has computed a new timeout value
127/// - `RESET`: CBT algorithm has been reset (e.g., after network change)
128///
129/// After a `RESET`, statistics are zeroed and recording is paused until
130/// the next `COMPUTED` event.
131///
132/// # See Also
133///
134/// - [`CircuitStat`] - Individual circuit tracking
135/// - [`crate::control`] - Event dispatch to CBT verification
136#[derive(Debug, Clone)]
137pub struct TimeoutStats {
138    /// Circuits currently being tracked (not yet built or timed out).
139    pub circuits: HashMap<String, CircuitStat>,
140    /// Total circuits launched.
141    pub all_launched: u64,
142    /// Total circuits successfully built.
143    pub all_built: u64,
144    /// Total circuits that timed out.
145    pub all_timeout: u64,
146    /// Hidden service circuits launched.
147    pub hs_launched: u64,
148    /// Hidden service circuits successfully built.
149    pub hs_built: u64,
150    /// Hidden service circuits that timed out.
151    pub hs_timeout: u64,
152    /// Whether to record timeouts (false after RESET, true after COMPUTED).
153    pub record_timeouts: bool,
154}
155
156impl Default for TimeoutStats {
157    fn default() -> Self {
158        Self::new()
159    }
160}
161
162impl TimeoutStats {
163    /// Creates a new timeout statistics tracker.
164    pub fn new() -> Self {
165        Self {
166            circuits: HashMap::new(),
167            all_launched: 0,
168            all_built: 0,
169            all_timeout: 0,
170            hs_launched: 0,
171            hs_built: 0,
172            hs_timeout: 0,
173            record_timeouts: true,
174        }
175    }
176
177    /// Resets all counters to zero.
178    pub fn zero_fields(&mut self) {
179        self.all_launched = 0;
180        self.all_built = 0;
181        self.all_timeout = 0;
182        self.hs_launched = 0;
183        self.hs_built = 0;
184        self.hs_timeout = 0;
185    }
186
187    /// Handles a circuit event.
188    ///
189    /// Tracks circuit state transitions and updates statistics accordingly.
190    ///
191    /// # Arguments
192    ///
193    /// * `circ_id` - The circuit ID
194    /// * `status` - The circuit status (LAUNCHED, BUILT, FAILED, CLOSED)
195    /// * `purpose` - The circuit purpose
196    /// * `hs_state` - The hidden service state (if any)
197    /// * `reason` - The close/fail reason (if any)
198    pub fn circ_event(
199        &mut self,
200        circ_id: &str,
201        status: &str,
202        purpose: &str,
203        hs_state: Option<&str>,
204        reason: Option<&str>,
205    ) {
206        let is_hs = hs_state.is_some() || purpose.starts_with("HS");
207
208        // Check for HS state change (non-HS to HS)
209        if is_hs {
210            if let Some(existing) = self.circuits.get(circ_id) {
211                if !existing.is_hs {
212                    plog(
213                        LogLevel::Error,
214                        &format!(
215                            "Circuit {} just changed from non-HS to HS: purpose={}, hs_state={:?}",
216                            circ_id, purpose, hs_state
217                        ),
218                    );
219                }
220            }
221        }
222
223        // Do not record circuits built while we have no timeout
224        // (ie: after reset but before computed)
225        if !self.record_timeouts {
226            return;
227        }
228
229        match status {
230            "LAUNCHED" => {
231                self.add_circuit(circ_id, is_hs);
232            }
233            "BUILT" => {
234                self.built_circuit(circ_id);
235            }
236            "FAILED" | "CLOSED" => {
237                if reason == Some("TIMEOUT") {
238                    self.timeout_circuit(circ_id);
239                } else if purpose != "MEASURE_TIMEOUT" {
240                    self.closed_circuit(circ_id);
241                }
242            }
243            _ => {}
244        }
245    }
246
247    /// Handles a CBT (Circuit Build Timeout) event.
248    ///
249    /// Processes COMPUTED and RESET events from Tor's circuit build timeout
250    /// algorithm.
251    ///
252    /// # Arguments
253    ///
254    /// * `set_type` - The CBT event type (COMPUTED, RESET)
255    /// * `timeout_rate` - Tor's reported timeout rate (if available)
256    pub fn cbt_event(&mut self, set_type: &str, timeout_rate: Option<f64>) {
257        if let Some(rate) = timeout_rate {
258            plog(
259                LogLevel::Info,
260                &format!(
261                    "CBT Timeout rate: {}; Our measured timeout rate: {:.4}; \
262                     Hidden service timeout rate: {:.4}",
263                    rate,
264                    self.timeout_rate_all(),
265                    self.timeout_rate_hs()
266                ),
267            );
268        }
269
270        match set_type {
271            "COMPUTED" => {
272                plog(LogLevel::Info, "CBT Timeout computed");
273                self.record_timeouts = true;
274            }
275            "RESET" => {
276                plog(LogLevel::Info, "CBT Timeout reset");
277                self.record_timeouts = false;
278                self.zero_fields();
279            }
280            _ => {}
281        }
282    }
283
284    /// Adds a new circuit to tracking.
285    pub fn add_circuit(&mut self, circ_id: &str, is_hs: bool) {
286        if self.circuits.contains_key(circ_id) {
287            plog(
288                LogLevel::Error,
289                &format!("Circuit {} already exists in map!", circ_id),
290            );
291        }
292        self.circuits
293            .insert(circ_id.to_string(), CircuitStat::new(circ_id, is_hs));
294        self.all_launched += 1;
295        if is_hs {
296            self.hs_launched += 1;
297        }
298    }
299
300    /// Records a circuit as successfully built.
301    pub fn built_circuit(&mut self, circ_id: &str) {
302        if let Some(circ) = self.circuits.remove(circ_id) {
303            self.all_built += 1;
304            if circ.is_hs {
305                self.hs_built += 1;
306            }
307        }
308    }
309
310    /// Records a circuit as closed before completion.
311    ///
312    /// If we are closed but still in circuits, then we closed before being
313    /// built or timing out. Don't count as a launched circuit.
314    pub fn closed_circuit(&mut self, circ_id: &str) {
315        if let Some(circ) = self.circuits.remove(circ_id) {
316            // Decrement launched count since this circuit didn't complete
317            self.all_launched = self.all_launched.saturating_sub(1);
318            if circ.is_hs {
319                self.hs_launched = self.hs_launched.saturating_sub(1);
320            }
321        }
322    }
323
324    /// Records a circuit as timed out.
325    pub fn timeout_circuit(&mut self, circ_id: &str) {
326        if let Some(circ) = self.circuits.remove(circ_id) {
327            self.all_timeout += 1;
328            if circ.is_hs {
329                self.hs_timeout += 1;
330            }
331        }
332    }
333
334    /// Calculates the timeout rate for all circuits.
335    ///
336    /// Returns the ratio of timed out circuits to launched circuits.
337    pub fn timeout_rate_all(&self) -> f64 {
338        if self.all_launched > 0 {
339            self.all_timeout as f64 / self.all_launched as f64
340        } else {
341            0.0
342        }
343    }
344
345    /// Calculates the timeout rate for hidden service circuits.
346    ///
347    /// Returns the ratio of timed out HS circuits to launched HS circuits.
348    pub fn timeout_rate_hs(&self) -> f64 {
349        if self.hs_launched > 0 {
350            self.hs_timeout as f64 / self.hs_launched as f64
351        } else {
352            0.0
353        }
354    }
355
356    /// Returns the number of circuits currently being tracked.
357    pub fn pending_count(&self) -> usize {
358        self.circuits.len()
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn test_circuit_stat_new() {
368        let stat = CircuitStat::new("123", true);
369        assert_eq!(stat.circ_id, "123");
370        assert!(stat.is_hs);
371    }
372
373    #[test]
374    fn test_timeout_stats_new() {
375        let stats = TimeoutStats::new();
376        assert!(stats.circuits.is_empty());
377        assert_eq!(stats.all_launched, 0);
378        assert_eq!(stats.all_built, 0);
379        assert_eq!(stats.all_timeout, 0);
380        assert_eq!(stats.hs_launched, 0);
381        assert_eq!(stats.hs_built, 0);
382        assert_eq!(stats.hs_timeout, 0);
383        assert!(stats.record_timeouts);
384    }
385
386    #[test]
387    fn test_add_circuit() {
388        let mut stats = TimeoutStats::new();
389
390        stats.add_circuit("123", false);
391        assert_eq!(stats.all_launched, 1);
392        assert_eq!(stats.hs_launched, 0);
393        assert!(stats.circuits.contains_key("123"));
394
395        stats.add_circuit("456", true);
396        assert_eq!(stats.all_launched, 2);
397        assert_eq!(stats.hs_launched, 1);
398    }
399
400    #[test]
401    fn test_built_circuit() {
402        let mut stats = TimeoutStats::new();
403
404        stats.add_circuit("123", true);
405        stats.built_circuit("123");
406
407        assert_eq!(stats.all_built, 1);
408        assert_eq!(stats.hs_built, 1);
409        assert!(!stats.circuits.contains_key("123"));
410    }
411
412    #[test]
413    fn test_timeout_circuit() {
414        let mut stats = TimeoutStats::new();
415
416        stats.add_circuit("123", true);
417        stats.timeout_circuit("123");
418
419        assert_eq!(stats.all_timeout, 1);
420        assert_eq!(stats.hs_timeout, 1);
421        assert!(!stats.circuits.contains_key("123"));
422    }
423
424    #[test]
425    fn test_closed_circuit() {
426        let mut stats = TimeoutStats::new();
427
428        stats.add_circuit("123", true);
429        assert_eq!(stats.all_launched, 1);
430        assert_eq!(stats.hs_launched, 1);
431
432        stats.closed_circuit("123");
433
434        // Closed before built/timeout - should decrement launched
435        assert_eq!(stats.all_launched, 0);
436        assert_eq!(stats.hs_launched, 0);
437        assert!(!stats.circuits.contains_key("123"));
438    }
439
440    #[test]
441    fn test_timeout_rate_all() {
442        let mut stats = TimeoutStats::new();
443
444        assert_eq!(stats.timeout_rate_all(), 0.0);
445
446        stats.add_circuit("1", false);
447        stats.add_circuit("2", false);
448        stats.add_circuit("3", false);
449        stats.add_circuit("4", false);
450
451        stats.built_circuit("1");
452        stats.built_circuit("2");
453        stats.built_circuit("3");
454        stats.timeout_circuit("4");
455
456        // 1 timeout out of 4 launched = 0.25
457        assert!((stats.timeout_rate_all() - 0.25).abs() < 0.001);
458    }
459
460    #[test]
461    fn test_timeout_rate_hs() {
462        let mut stats = TimeoutStats::new();
463
464        assert_eq!(stats.timeout_rate_hs(), 0.0);
465
466        stats.add_circuit("1", true);
467        stats.add_circuit("2", true);
468        stats.add_circuit("3", false);
469
470        stats.built_circuit("1");
471        stats.timeout_circuit("2");
472        stats.built_circuit("3");
473
474        // 1 HS timeout out of 2 HS launched = 0.5
475        assert!((stats.timeout_rate_hs() - 0.5).abs() < 0.001);
476    }
477
478    #[test]
479    fn test_zero_fields() {
480        let mut stats = TimeoutStats::new();
481
482        stats.all_launched = 10;
483        stats.all_built = 8;
484        stats.all_timeout = 2;
485        stats.hs_launched = 5;
486        stats.hs_built = 4;
487        stats.hs_timeout = 1;
488
489        stats.zero_fields();
490
491        assert_eq!(stats.all_launched, 0);
492        assert_eq!(stats.all_built, 0);
493        assert_eq!(stats.all_timeout, 0);
494        assert_eq!(stats.hs_launched, 0);
495        assert_eq!(stats.hs_built, 0);
496        assert_eq!(stats.hs_timeout, 0);
497    }
498
499    #[test]
500    fn test_cbt_event_reset() {
501        let mut stats = TimeoutStats::new();
502        stats.all_launched = 10;
503        stats.record_timeouts = true;
504
505        stats.cbt_event("RESET", None);
506
507        assert!(!stats.record_timeouts);
508        assert_eq!(stats.all_launched, 0);
509    }
510
511    #[test]
512    fn test_cbt_event_computed() {
513        let mut stats = TimeoutStats::new();
514        stats.record_timeouts = false;
515
516        stats.cbt_event("COMPUTED", Some(0.1));
517
518        assert!(stats.record_timeouts);
519    }
520
521    #[test]
522    fn test_circ_event_launched() {
523        let mut stats = TimeoutStats::new();
524
525        stats.circ_event(
526            "123",
527            "LAUNCHED",
528            "HS_SERVICE_REND",
529            Some("HSSR_CONNECTING"),
530            None,
531        );
532
533        assert_eq!(stats.all_launched, 1);
534        assert_eq!(stats.hs_launched, 1);
535        assert!(stats.circuits.contains_key("123"));
536    }
537
538    #[test]
539    fn test_circ_event_built() {
540        let mut stats = TimeoutStats::new();
541
542        stats.circ_event(
543            "123",
544            "LAUNCHED",
545            "HS_SERVICE_REND",
546            Some("HSSR_CONNECTING"),
547            None,
548        );
549        stats.circ_event(
550            "123",
551            "BUILT",
552            "HS_SERVICE_REND",
553            Some("HSSR_CONNECTING"),
554            None,
555        );
556
557        assert_eq!(stats.all_built, 1);
558        assert_eq!(stats.hs_built, 1);
559    }
560
561    #[test]
562    fn test_circ_event_timeout() {
563        let mut stats = TimeoutStats::new();
564
565        stats.circ_event("123", "LAUNCHED", "GENERAL", None, None);
566        stats.circ_event("123", "FAILED", "GENERAL", None, Some("TIMEOUT"));
567
568        assert_eq!(stats.all_timeout, 1);
569    }
570
571    #[test]
572    fn test_circ_event_closed_before_built() {
573        let mut stats = TimeoutStats::new();
574
575        stats.circ_event("123", "LAUNCHED", "GENERAL", None, None);
576        assert_eq!(stats.all_launched, 1);
577
578        stats.circ_event("123", "CLOSED", "GENERAL", None, Some("DESTROYED"));
579
580        // Should decrement launched since it closed before built/timeout
581        assert_eq!(stats.all_launched, 0);
582    }
583
584    #[test]
585    fn test_record_timeouts_disabled() {
586        let mut stats = TimeoutStats::new();
587        stats.record_timeouts = false;
588
589        stats.circ_event("123", "LAUNCHED", "GENERAL", None, None);
590
591        // Should not record when disabled
592        assert_eq!(stats.all_launched, 0);
593        assert!(!stats.circuits.contains_key("123"));
594    }
595
596    #[test]
597    fn test_hs_detection_by_purpose() {
598        let mut stats = TimeoutStats::new();
599
600        stats.circ_event("123", "LAUNCHED", "HS_CLIENT_REND", None, None);
601
602        assert_eq!(stats.hs_launched, 1);
603        assert!(stats.circuits.get("123").unwrap().is_hs);
604    }
605
606    #[test]
607    fn test_hs_detection_by_state() {
608        let mut stats = TimeoutStats::new();
609
610        stats.circ_event("123", "LAUNCHED", "GENERAL", Some("HSCI_CONNECTING"), None);
611
612        assert_eq!(stats.hs_launched, 1);
613        assert!(stats.circuits.get("123").unwrap().is_hs);
614    }
615
616    #[test]
617    fn test_initial_timeout_rates() {
618        let ts = TimeoutStats::new();
619        assert_eq!(ts.timeout_rate_hs(), 0.0);
620        assert_eq!(ts.timeout_rate_all(), 0.0);
621    }
622
623    #[test]
624    fn test_hs_timeout_rate_20_percent() {
625        let mut ts = TimeoutStats::new();
626
627        for i in 1..=8 {
628            let circ_id = format!("{}", i);
629            ts.circ_event(
630                &circ_id,
631                "LAUNCHED",
632                "HS_VANGUARDS",
633                Some("HSVI_CONNECTING"),
634                None,
635            );
636            ts.circ_event(
637                &circ_id,
638                "BUILT",
639                "HS_VANGUARDS",
640                Some("HSVI_CONNECTING"),
641                None,
642            );
643        }
644
645        ts.circ_event(
646            "9",
647            "LAUNCHED",
648            "HS_VANGUARDS",
649            Some("HSVI_CONNECTING"),
650            None,
651        );
652        ts.circ_event(
653            "9",
654            "FAILED",
655            "HS_VANGUARDS",
656            Some("HSVI_CONNECTING"),
657            Some("TIMEOUT"),
658        );
659        ts.circ_event(
660            "9",
661            "FAILED",
662            "MEASURE_TIMEOUT",
663            None,
664            Some("MEASUREMENT_EXPIRED"),
665        );
666
667        ts.circ_event(
668            "10",
669            "LAUNCHED",
670            "HS_VANGUARDS",
671            Some("HSVI_CONNECTING"),
672            None,
673        );
674        ts.circ_event(
675            "10",
676            "FAILED",
677            "HS_VANGUARDS",
678            Some("HSVI_CONNECTING"),
679            Some("TIMEOUT"),
680        );
681
682        assert!((ts.timeout_rate_hs() - 0.2).abs() < 0.001);
683        assert!((ts.timeout_rate_all() - 0.2).abs() < 0.001);
684    }
685
686    #[test]
687    fn test_general_circuits_dont_affect_hs_rate() {
688        let mut ts = TimeoutStats::new();
689
690        for i in 1..=8 {
691            let circ_id = format!("{}", i);
692            ts.circ_event(
693                &circ_id,
694                "LAUNCHED",
695                "HS_VANGUARDS",
696                Some("HSVI_CONNECTING"),
697                None,
698            );
699            ts.circ_event(
700                &circ_id,
701                "BUILT",
702                "HS_VANGUARDS",
703                Some("HSVI_CONNECTING"),
704                None,
705            );
706        }
707
708        ts.circ_event(
709            "9",
710            "LAUNCHED",
711            "HS_VANGUARDS",
712            Some("HSVI_CONNECTING"),
713            None,
714        );
715        ts.circ_event(
716            "9",
717            "FAILED",
718            "HS_VANGUARDS",
719            Some("HSVI_CONNECTING"),
720            Some("TIMEOUT"),
721        );
722
723        ts.circ_event(
724            "10",
725            "LAUNCHED",
726            "HS_VANGUARDS",
727            Some("HSVI_CONNECTING"),
728            None,
729        );
730        ts.circ_event(
731            "10",
732            "FAILED",
733            "HS_VANGUARDS",
734            Some("HSVI_CONNECTING"),
735            Some("TIMEOUT"),
736        );
737
738        for i in 11..=19 {
739            let circ_id = format!("{}", i);
740            ts.circ_event(&circ_id, "LAUNCHED", "GENERAL", None, None);
741            ts.circ_event(&circ_id, "BUILT", "GENERAL", None, None);
742        }
743
744        ts.circ_event("20", "LAUNCHED", "GENERAL", None, None);
745        ts.circ_event("20", "FAILED", "GENERAL", None, Some("TIMEOUT"));
746
747        assert!((ts.timeout_rate_hs() - 0.2).abs() < 0.001);
748        assert!((ts.timeout_rate_all() - 0.15).abs() < 0.001);
749    }
750
751    #[test]
752    fn test_failed_circuits_dont_impact_rates() {
753        let mut ts = TimeoutStats::new();
754
755        for i in 1..=8 {
756            let circ_id = format!("{}", i);
757            ts.circ_event(
758                &circ_id,
759                "LAUNCHED",
760                "HS_VANGUARDS",
761                Some("HSVI_CONNECTING"),
762                None,
763            );
764            ts.circ_event(
765                &circ_id,
766                "BUILT",
767                "HS_VANGUARDS",
768                Some("HSVI_CONNECTING"),
769                None,
770            );
771        }
772
773        ts.circ_event(
774            "9",
775            "LAUNCHED",
776            "HS_VANGUARDS",
777            Some("HSVI_CONNECTING"),
778            None,
779        );
780        ts.circ_event(
781            "9",
782            "FAILED",
783            "HS_VANGUARDS",
784            Some("HSVI_CONNECTING"),
785            Some("TIMEOUT"),
786        );
787
788        ts.circ_event(
789            "10",
790            "LAUNCHED",
791            "HS_VANGUARDS",
792            Some("HSVI_CONNECTING"),
793            None,
794        );
795        ts.circ_event(
796            "10",
797            "FAILED",
798            "HS_VANGUARDS",
799            Some("HSVI_CONNECTING"),
800            Some("TIMEOUT"),
801        );
802
803        let rate_before = ts.timeout_rate_hs();
804
805        ts.circ_event("21", "LAUNCHED", "GENERAL", None, None);
806        ts.circ_event("21", "FAILED", "GENERAL", None, Some("FINISHED"));
807
808        ts.circ_event(
809            "22",
810            "LAUNCHED",
811            "HS_VANGUARDS",
812            Some("HSVI_CONNECTING"),
813            None,
814        );
815        ts.circ_event(
816            "22",
817            "FAILED",
818            "HS_VANGUARDS",
819            Some("HSVI_CONNECTING"),
820            Some("FINISHED"),
821        );
822
823        assert!((ts.timeout_rate_hs() - rate_before).abs() < 0.001);
824    }
825
826    #[test]
827    fn test_closed_circuits_dont_impact_rates() {
828        let mut ts = TimeoutStats::new();
829
830        for i in 1..=8 {
831            let circ_id = format!("{}", i);
832            ts.circ_event(
833                &circ_id,
834                "LAUNCHED",
835                "HS_VANGUARDS",
836                Some("HSVI_CONNECTING"),
837                None,
838            );
839            ts.circ_event(
840                &circ_id,
841                "BUILT",
842                "HS_VANGUARDS",
843                Some("HSVI_CONNECTING"),
844                None,
845            );
846        }
847
848        ts.circ_event(
849            "9",
850            "LAUNCHED",
851            "HS_VANGUARDS",
852            Some("HSVI_CONNECTING"),
853            None,
854        );
855        ts.circ_event(
856            "9",
857            "FAILED",
858            "HS_VANGUARDS",
859            Some("HSVI_CONNECTING"),
860            Some("TIMEOUT"),
861        );
862
863        ts.circ_event(
864            "10",
865            "LAUNCHED",
866            "HS_VANGUARDS",
867            Some("HSVI_CONNECTING"),
868            None,
869        );
870        ts.circ_event(
871            "10",
872            "FAILED",
873            "HS_VANGUARDS",
874            Some("HSVI_CONNECTING"),
875            Some("TIMEOUT"),
876        );
877
878        let rate_before = ts.timeout_rate_hs();
879
880        ts.circ_event("23", "LAUNCHED", "GENERAL", None, None);
881        ts.circ_event("23", "CLOSED", "GENERAL", None, Some("FINISHED"));
882
883        ts.circ_event(
884            "24",
885            "LAUNCHED",
886            "HS_VANGUARDS",
887            Some("HSVI_CONNECTING"),
888            None,
889        );
890        ts.circ_event(
891            "24",
892            "CLOSED",
893            "HS_VANGUARDS",
894            Some("HSVI_CONNECTING"),
895            Some("FINISHED"),
896        );
897
898        assert!((ts.timeout_rate_hs() - rate_before).abs() < 0.001);
899    }
900
901    #[test]
902    fn test_circuits_not_counted_after_reset() {
903        let mut ts = TimeoutStats::new();
904
905        ts.cbt_event("RESET", None);
906        assert!(!ts.record_timeouts);
907
908        for i in 1..=8 {
909            let circ_id = format!("{}", i);
910            ts.circ_event(
911                &circ_id,
912                "LAUNCHED",
913                "HS_VANGUARDS",
914                Some("HSVI_CONNECTING"),
915                None,
916            );
917            ts.circ_event(
918                &circ_id,
919                "BUILT",
920                "HS_VANGUARDS",
921                Some("HSVI_CONNECTING"),
922                None,
923            );
924        }
925
926        ts.circ_event(
927            "9",
928            "LAUNCHED",
929            "HS_VANGUARDS",
930            Some("HSVI_CONNECTING"),
931            None,
932        );
933        ts.circ_event(
934            "9",
935            "FAILED",
936            "HS_VANGUARDS",
937            Some("HSVI_CONNECTING"),
938            Some("TIMEOUT"),
939        );
940
941        ts.circ_event(
942            "10",
943            "LAUNCHED",
944            "HS_VANGUARDS",
945            Some("HSVI_CONNECTING"),
946            None,
947        );
948        ts.circ_event(
949            "10",
950            "FAILED",
951            "HS_VANGUARDS",
952            Some("HSVI_CONNECTING"),
953            Some("TIMEOUT"),
954        );
955
956        assert_eq!(ts.timeout_rate_hs(), 0.0);
957        assert_eq!(ts.timeout_rate_all(), 0.0);
958    }
959
960    #[test]
961    fn test_circuits_counted_after_computed() {
962        let mut ts = TimeoutStats::new();
963
964        ts.cbt_event("RESET", None);
965        ts.cbt_event("COMPUTED", Some(0.1));
966
967        assert!(ts.record_timeouts);
968
969        for i in 1..=8 {
970            let circ_id = format!("{}", i);
971            ts.circ_event(
972                &circ_id,
973                "LAUNCHED",
974                "HS_VANGUARDS",
975                Some("HSVI_CONNECTING"),
976                None,
977            );
978            ts.circ_event(
979                &circ_id,
980                "BUILT",
981                "HS_VANGUARDS",
982                Some("HSVI_CONNECTING"),
983                None,
984            );
985        }
986
987        ts.circ_event(
988            "9",
989            "LAUNCHED",
990            "HS_VANGUARDS",
991            Some("HSVI_CONNECTING"),
992            None,
993        );
994        ts.circ_event(
995            "9",
996            "FAILED",
997            "HS_VANGUARDS",
998            Some("HSVI_CONNECTING"),
999            Some("TIMEOUT"),
1000        );
1001
1002        ts.circ_event(
1003            "10",
1004            "LAUNCHED",
1005            "HS_VANGUARDS",
1006            Some("HSVI_CONNECTING"),
1007            None,
1008        );
1009        ts.circ_event(
1010            "10",
1011            "FAILED",
1012            "HS_VANGUARDS",
1013            Some("HSVI_CONNECTING"),
1014            Some("TIMEOUT"),
1015        );
1016
1017        assert!((ts.timeout_rate_hs() - 0.2).abs() < 0.001);
1018        assert!((ts.timeout_rate_all() - 0.2).abs() < 0.001);
1019    }
1020
1021    #[test]
1022    fn test_double_launch_coverage() {
1023        let mut ts = TimeoutStats::new();
1024
1025        ts.circ_event(
1026            "25",
1027            "LAUNCHED",
1028            "HS_VANGUARDS",
1029            Some("HSVI_CONNECTING"),
1030            None,
1031        );
1032        ts.circ_event(
1033            "25",
1034            "LAUNCHED",
1035            "HS_VANGUARDS",
1036            Some("HSVI_CONNECTING"),
1037            None,
1038        );
1039
1040        ts.circ_event("25", "BUILT", "HS_VANGUARDS", Some("HSVI_CONNECTING"), None);
1041    }
1042}
1043
1044#[cfg(test)]
1045mod proptests {
1046    use super::*;
1047    use proptest::prelude::*;
1048
1049    proptest! {
1050        #![proptest_config(ProptestConfig::with_cases(100))]
1051
1052        #[test]
1053        fn cbt_statistics_accuracy(
1054            num_circuits in 1usize..50,
1055            outcomes in prop::collection::vec(prop_oneof![Just("BUILT"), Just("TIMEOUT"), Just("CLOSED")], 1..50),
1056            is_hs_flags in prop::collection::vec(any::<bool>(), 1..50),
1057        ) {
1058            let mut stats = TimeoutStats::new();
1059
1060            let mut expected_launched = 0u64;
1061            let mut expected_built = 0u64;
1062            let mut expected_timeout = 0u64;
1063            let mut expected_hs_launched = 0u64;
1064            let mut expected_hs_built = 0u64;
1065            let mut expected_hs_timeout = 0u64;
1066
1067            for i in 0..num_circuits.min(outcomes.len()).min(is_hs_flags.len()) {
1068                let circ_id = format!("{}", i);
1069                let is_hs = is_hs_flags[i];
1070                let outcome = outcomes[i];
1071
1072                stats.circ_event(&circ_id, "LAUNCHED", if is_hs { "HS_SERVICE_REND" } else { "GENERAL" },
1073                                if is_hs { Some("HSSR_CONNECTING") } else { None }, None);
1074                expected_launched += 1;
1075                if is_hs {
1076                    expected_hs_launched += 1;
1077                }
1078
1079                match outcome {
1080                    "BUILT" => {
1081                        stats.circ_event(&circ_id, "BUILT", if is_hs { "HS_SERVICE_REND" } else { "GENERAL" },
1082                                        if is_hs { Some("HSSR_CONNECTING") } else { None }, None);
1083                        expected_built += 1;
1084                        if is_hs {
1085                            expected_hs_built += 1;
1086                        }
1087                    }
1088                    "TIMEOUT" => {
1089                        stats.circ_event(&circ_id, "FAILED", if is_hs { "HS_SERVICE_REND" } else { "GENERAL" },
1090                                        if is_hs { Some("HSSR_CONNECTING") } else { None }, Some("TIMEOUT"));
1091                        expected_timeout += 1;
1092                        if is_hs {
1093                            expected_hs_timeout += 1;
1094                        }
1095                    }
1096                    "CLOSED" => {
1097                        stats.circ_event(&circ_id, "CLOSED", if is_hs { "HS_SERVICE_REND" } else { "GENERAL" },
1098                                        if is_hs { Some("HSSR_CONNECTING") } else { None }, Some("DESTROYED"));
1099                        expected_launched -= 1;
1100                        if is_hs {
1101                            expected_hs_launched -= 1;
1102                        }
1103                    }
1104                    _ => {}
1105                }
1106            }
1107
1108            prop_assert_eq!(stats.all_launched, expected_launched,
1109                "all_launched: expected {}, got {}", expected_launched, stats.all_launched);
1110            prop_assert_eq!(stats.all_built, expected_built,
1111                "all_built: expected {}, got {}", expected_built, stats.all_built);
1112            prop_assert_eq!(stats.all_timeout, expected_timeout,
1113                "all_timeout: expected {}, got {}", expected_timeout, stats.all_timeout);
1114            prop_assert_eq!(stats.hs_launched, expected_hs_launched,
1115                "hs_launched: expected {}, got {}", expected_hs_launched, stats.hs_launched);
1116            prop_assert_eq!(stats.hs_built, expected_hs_built,
1117                "hs_built: expected {}, got {}", expected_hs_built, stats.hs_built);
1118            prop_assert_eq!(stats.hs_timeout, expected_hs_timeout,
1119                "hs_timeout: expected {}, got {}", expected_hs_timeout, stats.hs_timeout);
1120
1121            if expected_launched > 0 {
1122                let expected_rate = expected_timeout as f64 / expected_launched as f64;
1123                prop_assert!((stats.timeout_rate_all() - expected_rate).abs() < 0.001,
1124                    "timeout_rate_all: expected {}, got {}", expected_rate, stats.timeout_rate_all());
1125            }
1126
1127            if expected_hs_launched > 0 {
1128                let expected_hs_rate = expected_hs_timeout as f64 / expected_hs_launched as f64;
1129                prop_assert!((stats.timeout_rate_hs() - expected_hs_rate).abs() < 0.001,
1130                    "timeout_rate_hs: expected {}, got {}", expected_hs_rate, stats.timeout_rate_hs());
1131            }
1132        }
1133    }
1134}