sidecheck_core/
sampler.rs1use anyhow::{Context, Result};
10use rand::seq::SliceRandom;
11use rand::Rng;
12use std::time::Instant;
13
14#[derive(Clone, Debug)]
18pub enum InjectionPoint {
19 Header(String),
20 Query(String),
21 JsonBody {
26 field: String,
27 template: Option<serde_json::Map<String, serde_json::Value>>,
28 },
29}
30
31impl InjectionPoint {
32 pub fn describe(&self) -> String {
33 match self {
34 InjectionPoint::Header(n) => format!("header {n}"),
35 InjectionPoint::Query(n) => format!("query param {n}"),
36 InjectionPoint::JsonBody { field, template } => match template {
37 Some(_) => format!("JSON field {field} (with body template)"),
38 None => format!("JSON field {field}"),
39 },
40 }
41 }
42}
43
44pub struct HttpTarget {
46 client: reqwest::blocking::Client,
47 url: String,
48 injection: InjectionPoint,
49}
50
51impl HttpTarget {
52 pub fn new(url: impl Into<String>, injection: InjectionPoint) -> Result<Self> {
53 Self::new_with_options(url, injection, false)
54 }
55
56 pub fn new_with_options(
57 url: impl Into<String>,
58 injection: InjectionPoint,
59 accept_invalid_certs: bool,
60 ) -> Result<Self> {
61 let client = reqwest::blocking::Client::builder()
62 .timeout(std::time::Duration::from_secs(10))
63 .pool_max_idle_per_host(4)
66 .danger_accept_invalid_certs(accept_invalid_certs)
67 .build()
68 .context("failed to build HTTP client")?;
69 Ok(Self {
70 client,
71 url: url.into(),
72 injection,
73 })
74 }
75
76 pub fn measure(&self, value: &str) -> Result<f64> {
79 let start = Instant::now();
80
81 let resp = match &self.injection {
82 InjectionPoint::Header(name) => self
83 .client
84 .get(&self.url)
85 .header(name.as_str(), value)
86 .send(),
87 InjectionPoint::Query(name) => self
88 .client
89 .get(&self.url)
90 .query(&[(name.as_str(), value)])
91 .send(),
92 InjectionPoint::JsonBody { field, template } => {
93 let mut body = template.clone().unwrap_or_default();
94 body.insert(field.clone(), serde_json::Value::String(value.to_string()));
95 self.client
96 .post(&self.url)
97 .json(&serde_json::Value::Object(body))
98 .send()
99 }
100 }
101 .context("request failed")?;
102
103 let _ = resp.bytes().context("failed to read response body")?;
105 Ok(start.elapsed().as_secs_f64())
106 }
107}
108
109pub fn random_wrong_value(secret: &str, rng: &mut impl Rng) -> String {
114 const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
115 loop {
116 let candidate: String = (0..secret.len())
117 .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
118 .collect();
119 if candidate != secret {
120 return candidate;
121 }
122 }
123}
124
125#[derive(Debug, Default)]
126pub struct RawSamples {
127 pub class_a: Vec<f64>,
128 pub class_b: Vec<f64>,
129 pub failures: usize,
133}
134
135pub fn run_interleaved(
141 target: &HttpTarget,
142 value_a: &str,
143 value_b: &str,
144 n_per_class: usize,
145 block_size: usize,
146 rng: &mut impl Rng,
147 mut on_progress: impl FnMut(usize, usize),
148) -> Result<RawSamples> {
149 const MAX_FAILURE_RATIO: f64 = 0.1;
150
151 let mut result = RawSamples::default();
152 let mut remaining_a = n_per_class;
153 let mut remaining_b = n_per_class;
154 let total = n_per_class * 2;
155 let mut done = 0;
156
157 while remaining_a > 0 || remaining_b > 0 {
158 let mut block: Vec<bool> = Vec::new(); block.extend(std::iter::repeat_n(true, block_size.min(remaining_a)));
160 block.extend(std::iter::repeat_n(false, block_size.min(remaining_b)));
161 block.shuffle(rng);
162
163 for is_a in block {
164 let measurement = if is_a {
165 remaining_a -= 1;
166 target.measure(value_a)
167 } else {
168 remaining_b -= 1;
169 target.measure(value_b)
170 };
171
172 match measurement {
173 Ok(elapsed) => {
174 if is_a {
175 result.class_a.push(elapsed);
176 } else {
177 result.class_b.push(elapsed);
178 }
179 }
180 Err(_) => {
181 result.failures += 1;
182 }
183 }
184
185 done += 1;
186 on_progress(done, total);
187
188 let attempted = result.class_a.len() + result.class_b.len() + result.failures;
189 if attempted > 100 && (result.failures as f64 / attempted as f64) > MAX_FAILURE_RATIO {
190 anyhow::bail!(
191 "aborting: {} of {} requests failed ({}%). the target or network is too \
192 unstable for a reliable measurement — fix connectivity first.",
193 result.failures,
194 attempted,
195 (result.failures as f64 / attempted as f64 * 100.0) as u32
196 );
197 }
198 }
199 }
200
201 Ok(result)
202}
203
204#[derive(Debug, Default)]
207pub struct PlainSamples {
208 pub latencies: Vec<f64>,
209 pub failures: usize,
210}
211
212pub fn collect_plain(
216 target: &HttpTarget,
217 value: &str,
218 n: usize,
219 mut on_progress: impl FnMut(usize, usize),
220) -> PlainSamples {
221 let mut result = PlainSamples::default();
222 for i in 0..n {
223 match target.measure(value) {
224 Ok(elapsed) => result.latencies.push(elapsed),
225 Err(_) => result.failures += 1,
226 }
227 on_progress(i + 1, n);
228 }
229 result
230}