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 {
50 client: reqwest::blocking::Client,
51 url: String,
52 injection: InjectionPoint,
53 extra_headers: Vec<(String, String)>,
54}
55
56impl HttpTarget {
57 pub fn new(url: impl Into<String>, injection: InjectionPoint) -> Result<Self> {
58 Self::new_with_options(url, injection, false, Vec::new())
59 }
60
61 pub fn new_with_options(
62 url: impl Into<String>,
63 injection: InjectionPoint,
64 accept_invalid_certs: bool,
65 extra_headers: Vec<(String, String)>,
66 ) -> Result<Self> {
67 let client = reqwest::blocking::Client::builder()
68 .timeout(std::time::Duration::from_secs(10))
69 .pool_max_idle_per_host(4)
72 .danger_accept_invalid_certs(accept_invalid_certs)
73 .build()
74 .context("failed to build HTTP client")?;
75 Ok(Self {
76 client,
77 url: url.into(),
78 injection,
79 extra_headers,
80 })
81 }
82
83 pub fn measure(&self, value: &str) -> Result<f64> {
87 let start = Instant::now();
88
89 let builder = match &self.injection {
90 InjectionPoint::Header(name) => self.client.get(&self.url).header(name.as_str(), value),
91 InjectionPoint::Query(name) => {
92 self.client.get(&self.url).query(&[(name.as_str(), value)])
93 }
94 InjectionPoint::JsonBody { field, template } => {
95 let mut body = template.clone().unwrap_or_default();
96 body.insert(field.clone(), serde_json::Value::String(value.to_string()));
97 self.client
98 .post(&self.url)
99 .json(&serde_json::Value::Object(body))
100 }
101 };
102
103 let builder = self
104 .extra_headers
105 .iter()
106 .fold(builder, |b, (name, value)| b.header(name, value));
107
108 let resp = builder.send().context("request failed")?;
109
110 let _ = resp.bytes().context("failed to read response body")?;
113 Ok(start.elapsed().as_secs_f64())
114 }
115}
116
117pub fn random_wrong_value(secret: &str, rng: &mut impl Rng) -> String {
123 const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
124 loop {
125 let candidate: String = (0..secret.len())
126 .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
127 .collect();
128 if candidate != secret {
129 return candidate;
130 }
131 }
132}
133
134#[derive(Debug, Default)]
135pub struct RawSamples {
136 pub class_a: Vec<f64>,
137 pub class_b: Vec<f64>,
138 pub failures: usize,
142}
143
144pub fn run_interleaved(
150 target: &HttpTarget,
151 value_a: &str,
152 value_b: &str,
153 n_per_class: usize,
154 block_size: usize,
155 rng: &mut impl Rng,
156 mut on_progress: impl FnMut(usize, usize),
157) -> Result<RawSamples> {
158 const MAX_FAILURE_RATIO: f64 = 0.1;
159
160 let mut result = RawSamples::default();
161 let mut remaining_a = n_per_class;
162 let mut remaining_b = n_per_class;
163 let total = n_per_class * 2;
164 let mut done = 0;
165
166 while remaining_a > 0 || remaining_b > 0 {
167 let mut block: Vec<bool> = Vec::new(); block.extend(std::iter::repeat_n(true, block_size.min(remaining_a)));
169 block.extend(std::iter::repeat_n(false, block_size.min(remaining_b)));
170 block.shuffle(rng);
171
172 for is_a in block {
173 let measurement = if is_a {
174 remaining_a -= 1;
175 target.measure(value_a)
176 } else {
177 remaining_b -= 1;
178 target.measure(value_b)
179 };
180
181 match measurement {
182 Ok(elapsed) => {
183 if is_a {
184 result.class_a.push(elapsed);
185 } else {
186 result.class_b.push(elapsed);
187 }
188 }
189 Err(_) => {
190 result.failures += 1;
191 }
192 }
193
194 done += 1;
195 on_progress(done, total);
196
197 let attempted = result.class_a.len() + result.class_b.len() + result.failures;
198 if attempted > 100 && (result.failures as f64 / attempted as f64) > MAX_FAILURE_RATIO {
199 anyhow::bail!(
200 "aborting: {} of {} requests failed ({}%). the target or network is too \
201 unstable for a reliable measurement — fix connectivity first.",
202 result.failures,
203 attempted,
204 (result.failures as f64 / attempted as f64 * 100.0) as u32
205 );
206 }
207 }
208 }
209
210 Ok(result)
211}
212
213#[derive(Debug, Default)]
217pub struct PlainSamples {
218 pub latencies: Vec<f64>,
219 pub failures: usize,
220}
221
222pub fn collect_plain(
227 target: &HttpTarget,
228 value: &str,
229 n: usize,
230 mut on_progress: impl FnMut(usize, usize),
231) -> PlainSamples {
232 let mut result = PlainSamples::default();
233 for i in 0..n {
234 match target.measure(value) {
235 Ok(elapsed) => result.latencies.push(elapsed),
236 Err(_) => result.failures += 1,
237 }
238 on_progress(i + 1, n);
239 }
240 result
241}