Skip to main content

sidecheck_core/
doctor.rs

1//! `sidecheck doctor` — быстрая pre-flight проверка канала до цели, ДО того
2//! как тратить время на полноценный тест. Отвечает на вопрос "стоит ли вообще
3//! пробовать измерять timing-утечку по этому пути", а не "есть ли утечка".
4
5use crate::stats::{estimate_jitter, percentile, required_samples};
6
7/// Условный размер "типичной" реальной утечки от небезопасного сравнения —
8/// не искусственно усиленной, а такой, какая бывает от `==` вместо
9/// constant-time сравнения в реальном скомпилированном коде: единицы
10/// микросекунд. Используется только чтобы дать осмысленную рекомендацию по
11/// числу сэмплов ДО того, как у нас появится реальная оценка эффекта из
12/// пилотного прогона check — сам doctor ничего не сравнивает, ему неоткуда
13/// взять реальный размер эффекта.
14const TYPICAL_LEAK_SECONDS: f64 = 1e-6;
15
16pub struct DoctorReport {
17    pub target: String,
18    pub samples: usize,
19    pub median_rtt_seconds: f64,
20    pub jitter_seconds: f64,
21    pub packet_loss_ratio: f64,
22    pub recommended_samples: u64,
23}
24
25#[derive(Debug, PartialEq, Eq)]
26pub enum JitterLevel {
27    Low,
28    Medium,
29    High,
30}
31
32impl JitterLevel {
33    fn label(&self) -> &'static str {
34        match self {
35            JitterLevel::Low => "low",
36            JitterLevel::Medium => "medium",
37            JitterLevel::High => "high",
38        }
39    }
40}
41
42/// Грубая классификация джиттера. Пороги подобраны по практике: до 1мс —
43/// локальная сеть/loopback, единицы миллисекунд — тот же датацентр или
44/// хороший LAN, десятки миллисекунд — типичный публичный интернет.
45fn classify_jitter(jitter_seconds: f64) -> JitterLevel {
46    if jitter_seconds < 0.001 {
47        JitterLevel::Low
48    } else if jitter_seconds < 0.010 {
49        JitterLevel::Medium
50    } else {
51        JitterLevel::High
52    }
53}
54
55#[derive(Debug, PartialEq, Eq)]
56pub enum EnvironmentQuality {
57    Good,
58    Fair,
59    Poor,
60}
61
62impl EnvironmentQuality {
63    fn label(&self) -> &'static str {
64        match self {
65            EnvironmentQuality::Good => "GOOD",
66            EnvironmentQuality::Fair => "FAIR",
67            EnvironmentQuality::Poor => "POOR",
68        }
69    }
70}
71
72impl DoctorReport {
73    pub fn from_measurements(target: String, latencies: &[f64], failures: usize) -> Self {
74        let samples = latencies.len();
75        let attempted = samples + failures;
76        let packet_loss_ratio = if attempted > 0 {
77            failures as f64 / attempted as f64
78        } else {
79            1.0
80        };
81
82        let mut sorted = latencies.to_vec();
83        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
84        let median_rtt_seconds = if sorted.is_empty() {
85            0.0
86        } else {
87            percentile(&sorted, 50.0)
88        };
89
90        // используем ту же (теперь робастную к выбросам, MAD-based) оценку
91        // джиттера, что и основной pipeline check — раньше здесь был
92        // отдельный расчёт через дисперсию, который расходился с check
93        // на реальных данных из-за чувствительности к единичным выбросам
94        let jitter_seconds = estimate_jitter(latencies);
95
96        let recommended_samples = if jitter_seconds > 0.0 {
97            required_samples(jitter_seconds, TYPICAL_LEAK_SECONDS, 0.95)
98        } else {
99            0
100        };
101
102        Self {
103            target,
104            samples,
105            median_rtt_seconds,
106            jitter_seconds,
107            packet_loss_ratio,
108            recommended_samples,
109        }
110    }
111
112    fn quality(&self) -> EnvironmentQuality {
113        let jitter = classify_jitter(self.jitter_seconds);
114        if self.packet_loss_ratio > 0.05 {
115            return EnvironmentQuality::Poor;
116        }
117        match jitter {
118            JitterLevel::Low => EnvironmentQuality::Good,
119            JitterLevel::Medium => {
120                if self.packet_loss_ratio > 0.0 {
121                    EnvironmentQuality::Fair
122                } else {
123                    EnvironmentQuality::Good
124                }
125            }
126            JitterLevel::High => EnvironmentQuality::Poor,
127        }
128    }
129
130    pub fn render(&self) -> String {
131        let jitter_level = classify_jitter(self.jitter_seconds);
132        let quality = self.quality();
133
134        let mut out = String::new();
135        out.push_str(&"─".repeat(48));
136        out.push_str("\nsidecheck doctor\n");
137        out.push_str(&"─".repeat(48));
138        out.push_str(&format!("\n\ntarget                {}\n", self.target));
139        out.push_str(&format!("samples                {}\n\n", self.samples));
140        out.push_str(&format!(
141            "median RTT:            {:.1} ms\n",
142            self.median_rtt_seconds * 1000.0
143        ));
144        out.push_str(&format!(
145            "RTT jitter:            {:.2} ms ({})\n",
146            self.jitter_seconds * 1000.0,
147            jitter_level.label()
148        ));
149        out.push_str(&format!(
150            "packet loss:           {:.1}%\n",
151            self.packet_loss_ratio * 100.0
152        ));
153        if self.recommended_samples > 50_000_000 {
154            out.push_str("recommended samples:   effectively unbounded — a ~1μs leak is not\n                       reliably measurable over this path\n");
155        } else {
156            out.push_str(&format!(
157                "recommended samples:   ~{} (to reliably detect a ~1μs leak, the\n                       rough scale of a real == vs constant-time bug)\n",
158                self.recommended_samples
159            ));
160        }
161        out.push_str(&format!("environment quality:   {}\n", quality.label()));
162
163        out.push_str(&"─".repeat(48));
164        out.push('\n');
165        match quality {
166            EnvironmentQuality::Good => {
167                out.push_str("this path looks suitable for timing measurement. proceed with `sidecheck check`.\n");
168            }
169            EnvironmentQuality::Fair => {
170                out.push_str(
171                    "usable, but expect to need a larger sample size for small leaks. \
172                     `sidecheck check` will size the run automatically based on what it finds.\n",
173                );
174            }
175            EnvironmentQuality::Poor => {
176                out.push_str(
177                    "this path is too noisy/lossy for reliable timing measurement of a \
178                     realistic-sized leak. this is a property of the network path, not proof \
179                     the endpoint is safe. test from a lower-latency vantage point (same \
180                     LAN/datacenter as the target, or from the server itself) if you can.\n",
181                );
182            }
183        }
184
185        out
186    }
187}