Skip to main content

sidecheck_core/
sampler.rs

1//! Сбор сырых измерений времени отклика.
2//!
3//! Ключевое требование методологии: классы запросов (например, "верный
4//! префикс" / "неверный префикс") должны чередоваться в случайном порядке,
5//! а не идти блоками "сначала все A, потом все B" — иначе на результат
6//! повлияет прогрев сервера, фоновая нагрузка или дрейф сети во времени,
7//! а не сама утечка. См. dudect / Crosby-Wallach методологию.
8
9use anyhow::{Context, Result};
10use rand::seq::SliceRandom;
11use rand::Rng;
12use std::time::Instant;
13
14/// Куда подставляется тестовое значение. Header — самый частый случай для
15/// API-ключей, Query — для legacy-эндпоинтов с токеном в URL, JsonBody —
16/// для типичных JSON-логинов (POST /login {"password": "..."}).
17#[derive(Clone, Debug)]
18pub enum InjectionPoint {
19    Header(String),
20    Query(String),
21    /// Имя поля в плоском JSON-объекте тела запроса
22    JsonBody(String),
23}
24
25impl InjectionPoint {
26    pub fn describe(&self) -> String {
27        match self {
28            InjectionPoint::Header(n) => format!("header {n}"),
29            InjectionPoint::Query(n) => format!("query param {n}"),
30            InjectionPoint::JsonBody(n) => format!("JSON field {n}"),
31        }
32    }
33}
34
35/// HTTP-цель: URL и точка, в которую подставляется тестовое значение.
36pub struct HttpTarget {
37    client: reqwest::blocking::Client,
38    url: String,
39    injection: InjectionPoint,
40}
41
42impl HttpTarget {
43    pub fn new(url: impl Into<String>, injection: InjectionPoint) -> Result<Self> {
44        Self::new_with_options(url, injection, false)
45    }
46
47    pub fn new_with_options(
48        url: impl Into<String>,
49        injection: InjectionPoint,
50        accept_invalid_certs: bool,
51    ) -> Result<Self> {
52        let client = reqwest::blocking::Client::builder()
53            .timeout(std::time::Duration::from_secs(10))
54            // важно: не подтягивать TCP keep-alive пул иначе первый запрос
55            // в каждом классе будет медленнее из-за установки соединения
56            .pool_max_idle_per_host(4)
57            .danger_accept_invalid_certs(accept_invalid_certs)
58            .build()
59            .context("failed to build HTTP client")?;
60        Ok(Self {
61            client,
62            url: url.into(),
63            injection,
64        })
65    }
66
67    /// Один замер: отправляет запрос с заданным значением в настроенной
68    /// точке инъекции, возвращает время до получения полного ответа в секундах.
69    pub fn measure(&self, value: &str) -> Result<f64> {
70        let start = Instant::now();
71
72        let resp = match &self.injection {
73            InjectionPoint::Header(name) => self
74                .client
75                .get(&self.url)
76                .header(name.as_str(), value)
77                .send(),
78            InjectionPoint::Query(name) => self
79                .client
80                .get(&self.url)
81                .query(&[(name.as_str(), value)])
82                .send(),
83            InjectionPoint::JsonBody(field) => {
84                let body = serde_json::json!({ field: value });
85                self.client.post(&self.url).json(&body).send()
86            }
87        }
88        .context("request failed")?;
89
90        // важно дочитать тело — иначе замер не включает полное время ответа
91        let _ = resp.bytes().context("failed to read response body")?;
92        Ok(start.elapsed().as_secs_f64())
93    }
94}
95
96/// Генерирует заведомо неверное значение той же длины, что и реальный
97/// секрет — чтобы длина payload не была отдельной переменной, искажающей
98/// измерение (см. предупреждение в CLI про разную длину value_a/value_b).
99/// Принимает внешний RNG, чтобы весь прогон был воспроизводим по одному seed.
100pub fn random_wrong_value(secret: &str, rng: &mut impl Rng) -> String {
101    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
102    loop {
103        let candidate: String = (0..secret.len())
104            .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
105            .collect();
106        if candidate != secret {
107            return candidate;
108        }
109    }
110}
111
112#[derive(Debug, Default)]
113pub struct RawSamples {
114    pub class_a: Vec<f64>,
115    pub class_b: Vec<f64>,
116    /// число запросов, которые не удалось выполнить (таймаут, разрыв
117    /// соединения и т.п.) — не считаются в статистике, но если их много,
118    /// результату нельзя доверять
119    pub failures: usize,
120}
121
122/// Прогоняет n_per_class измерений на каждый класс, чередуя их случайными
123/// блоками, чтобы усреднить влияние дрейфа во времени. Одиночные сбои сети
124/// не прерывают весь прогон — считаются и репортятся отдельно, но если
125/// доля сбоев превышает max_failure_ratio, прогон останавливается: на таком
126/// нестабильном канале доверять таймингам нельзя.
127pub fn run_interleaved(
128    target: &HttpTarget,
129    value_a: &str,
130    value_b: &str,
131    n_per_class: usize,
132    block_size: usize,
133    rng: &mut impl Rng,
134    mut on_progress: impl FnMut(usize, usize),
135) -> Result<RawSamples> {
136    const MAX_FAILURE_RATIO: f64 = 0.1;
137
138    let mut result = RawSamples::default();
139    let mut remaining_a = n_per_class;
140    let mut remaining_b = n_per_class;
141    let total = n_per_class * 2;
142    let mut done = 0;
143
144    while remaining_a > 0 || remaining_b > 0 {
145        let mut block: Vec<bool> = Vec::new(); // true = class A
146        block.extend(std::iter::repeat_n(true, block_size.min(remaining_a)));
147        block.extend(std::iter::repeat_n(false, block_size.min(remaining_b)));
148        block.shuffle(rng);
149
150        for is_a in block {
151            let measurement = if is_a {
152                remaining_a -= 1;
153                target.measure(value_a)
154            } else {
155                remaining_b -= 1;
156                target.measure(value_b)
157            };
158
159            match measurement {
160                Ok(elapsed) => {
161                    if is_a {
162                        result.class_a.push(elapsed);
163                    } else {
164                        result.class_b.push(elapsed);
165                    }
166                }
167                Err(_) => {
168                    result.failures += 1;
169                }
170            }
171
172            done += 1;
173            on_progress(done, total);
174
175            let attempted = result.class_a.len() + result.class_b.len() + result.failures;
176            if attempted > 100 && (result.failures as f64 / attempted as f64) > MAX_FAILURE_RATIO {
177                anyhow::bail!(
178                    "aborting: {} of {} requests failed ({}%). the target or network is too \
179                     unstable for a reliable measurement — fix connectivity first.",
180                    result.failures,
181                    attempted,
182                    (result.failures as f64 / attempted as f64 * 100.0) as u32
183                );
184            }
185        }
186    }
187
188    Ok(result)
189}
190
191/// Результат простого сбора замеров для `sidecheck doctor` — без деления
192/// на классы, нас интересует только форма распределения RTT до цели.
193#[derive(Debug, Default)]
194pub struct PlainSamples {
195    pub latencies: Vec<f64>,
196    pub failures: usize,
197}
198
199/// Собирает n последовательных замеров одного и того же запроса. В отличие
200/// от run_interleaved — не прерывается на потерях пакетов, а считает их: для
201/// doctor-режима сам процент потерь и есть часть диагноза, а не повод остановиться.
202pub fn collect_plain(
203    target: &HttpTarget,
204    value: &str,
205    n: usize,
206    mut on_progress: impl FnMut(usize, usize),
207) -> PlainSamples {
208    let mut result = PlainSamples::default();
209    for i in 0..n {
210        match target.measure(value) {
211            Ok(elapsed) => result.latencies.push(elapsed),
212            Err(_) => result.failures += 1,
213        }
214        on_progress(i + 1, n);
215    }
216    result
217}