Skip to main content

sidecheck_core/
doctor.rs

1//! `sidecheck doctor` is a measurement feasibility estimator: a fast
2//! pre-flight check of the path to the target, run BEFORE spending time on
3//! a full test. It answers "is it even worth trying to measure a timing
4//! leak over this path", not "is there a leak".
5
6use crate::stats::{estimate_jitter, percentile, required_samples};
7
8/// A conservative size for a "typical" real leak from an unsafe
9/// comparison — not artificially amplified, but the kind of gap that
10/// `==` instead of a constant-time comparison actually produces in real
11/// compiled code: single-digit microseconds. Only used to give a
12/// meaningful sample-count recommendation BEFORE we have a real effect
13/// size from check's pilot run — doctor itself doesn't compare anything,
14/// it has no real effect size to draw on.
15const TYPICAL_LEAK_SECONDS: f64 = 1e-6;
16
17pub struct DoctorReport {
18    pub target: String,
19    pub samples: usize,
20    pub median_rtt_seconds: f64,
21    pub jitter_seconds: f64,
22    pub packet_loss_ratio: f64,
23    pub recommended_samples: u64,
24}
25
26#[derive(Debug, PartialEq, Eq)]
27pub enum JitterLevel {
28    Low,
29    Medium,
30    High,
31}
32
33impl JitterLevel {
34    fn label(&self) -> &'static str {
35        match self {
36            JitterLevel::Low => "low",
37            JitterLevel::Medium => "medium",
38            JitterLevel::High => "high",
39        }
40    }
41}
42
43/// Rough jitter classification. Thresholds picked from practice: under 1ms
44/// is local network/loopback, single-digit milliseconds is same
45/// datacenter or a good LAN, tens of milliseconds is typical public
46/// internet.
47fn classify_jitter(jitter_seconds: f64) -> JitterLevel {
48    if jitter_seconds < 0.001 {
49        JitterLevel::Low
50    } else if jitter_seconds < 0.010 {
51        JitterLevel::Medium
52    } else {
53        JitterLevel::High
54    }
55}
56
57#[derive(Debug, PartialEq, Eq)]
58pub enum EnvironmentQuality {
59    Good,
60    Fair,
61    Poor,
62}
63
64impl EnvironmentQuality {
65    fn label(&self) -> &'static str {
66        match self {
67            EnvironmentQuality::Good => "GOOD",
68            EnvironmentQuality::Fair => "FAIR",
69            EnvironmentQuality::Poor => "POOR",
70        }
71    }
72}
73
74impl DoctorReport {
75    pub fn from_measurements(target: String, latencies: &[f64], failures: usize) -> Self {
76        let samples = latencies.len();
77        let attempted = samples + failures;
78        let packet_loss_ratio = if attempted > 0 {
79            failures as f64 / attempted as f64
80        } else {
81            1.0
82        };
83
84        let mut sorted = latencies.to_vec();
85        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
86        let median_rtt_seconds = if sorted.is_empty() {
87            0.0
88        } else {
89            percentile(&sorted, 50.0)
90        };
91
92        // use the same (now outlier-robust, MAD-based) jitter estimate as
93        // the main check pipeline — this used to be a separate
94        // variance-based calculation that diverged from check on real
95        // data due to sensitivity to single outliers
96        let jitter_seconds = estimate_jitter(latencies);
97
98        let recommended_samples = if jitter_seconds > 0.0 {
99            required_samples(jitter_seconds, TYPICAL_LEAK_SECONDS, 0.95)
100        } else {
101            0
102        };
103
104        Self {
105            target,
106            samples,
107            median_rtt_seconds,
108            jitter_seconds,
109            packet_loss_ratio,
110            recommended_samples,
111        }
112    }
113
114    fn quality(&self) -> EnvironmentQuality {
115        let jitter = classify_jitter(self.jitter_seconds);
116        if self.packet_loss_ratio > 0.05 {
117            return EnvironmentQuality::Poor;
118        }
119        match jitter {
120            JitterLevel::Low => EnvironmentQuality::Good,
121            JitterLevel::Medium => {
122                if self.packet_loss_ratio > 0.0 {
123                    EnvironmentQuality::Fair
124                } else {
125                    EnvironmentQuality::Good
126                }
127            }
128            JitterLevel::High => EnvironmentQuality::Poor,
129        }
130    }
131
132    pub fn render(&self) -> String {
133        let jitter_level = classify_jitter(self.jitter_seconds);
134        let quality = self.quality();
135
136        let mut out = String::new();
137        out.push_str(&"─".repeat(48));
138        out.push_str("\nsidecheck doctor\n");
139        out.push_str(&"─".repeat(48));
140        out.push_str(&format!("\n\ntarget                {}\n", self.target));
141        out.push_str(&format!("samples                {}\n\n", self.samples));
142        out.push_str(&format!(
143            "median RTT:            {:.1} ms\n",
144            self.median_rtt_seconds * 1000.0
145        ));
146        out.push_str(&format!(
147            "RTT jitter:            {:.2} ms ({})\n",
148            self.jitter_seconds * 1000.0,
149            jitter_level.label()
150        ));
151        out.push_str(&format!(
152            "packet loss:           {:.1}%\n",
153            self.packet_loss_ratio * 100.0
154        ));
155        if self.recommended_samples > 50_000_000 {
156            out.push_str("recommended samples:   effectively unbounded — a ~1μs leak is not\n                       reliably measurable over this path\n");
157        } else {
158            out.push_str(&format!(
159                "recommended samples:   ~{} (to reliably detect a ~1μs leak, the\n                       rough scale of a real == vs constant-time bug)\n",
160                self.recommended_samples
161            ));
162        }
163        out.push_str(&format!("environment quality:   {}\n", quality.label()));
164
165        out.push_str(&"─".repeat(48));
166        out.push('\n');
167        match quality {
168            EnvironmentQuality::Good => {
169                out.push_str("this path looks suitable for timing measurement. proceed with `sidecheck check`.\n");
170            }
171            EnvironmentQuality::Fair => {
172                out.push_str(
173                    "usable, but expect to need a larger sample size for small leaks. \
174                     `sidecheck check` will size the run automatically based on what it finds.\n",
175                );
176            }
177            EnvironmentQuality::Poor => {
178                out.push_str(
179                    "this path is too noisy/lossy for reliable timing measurement of a \
180                     realistic-sized leak. this is a property of the network path, not proof \
181                     the endpoint is safe. test from a lower-latency vantage point (same \
182                     LAN/datacenter as the target, or from the server itself) if you can.\n",
183                );
184            }
185        }
186
187        out
188    }
189}