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(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
35pub 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 .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 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 let _ = resp.bytes().context("failed to read response body")?;
92 Ok(start.elapsed().as_secs_f64())
93 }
94}
95
96pub 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 pub failures: usize,
120}
121
122pub 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(); 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#[derive(Debug, Default)]
194pub struct PlainSamples {
195 pub latencies: Vec<f64>,
196 pub failures: usize,
197}
198
199pub 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}