stygian_browser/transport_realism/
observations.rs1use serde::{Deserialize, Serialize};
12
13use crate::tls_validation::CHROME_136_HTTP2_SETTINGS;
14
15pub const HEADER_ORDER_CHROME_136: &[&str] = &[
22 "host",
23 "connection",
24 "sec-ch-ua",
25 "sec-ch-ua-mobile",
26 "sec-ch-ua-platform",
27 "user-agent",
28 "accept",
29 "sec-fetch-site",
30 "sec-fetch-mode",
31 "sec-fetch-user",
32 "sec-fetch-dest",
33 "accept-encoding",
34 "accept-language",
35 "cookie",
36];
37
38pub const HEADER_ORDER_FIREFOX_130: &[&str] = &[
44 "host",
45 "user-agent",
46 "accept",
47 "accept-language",
48 "accept-encoding",
49 "connection",
50 "cookie",
51 "sec-fetch-dest",
52 "sec-fetch-mode",
53 "sec-fetch-site",
54 "sec-fetch-user",
55];
56
57pub const PSEUDO_HEADER_ORDER_CHROME_136: &[&str] = &[":method", ":authority", ":scheme", ":path"];
62
63pub type Http2SettingsObservation = Vec<(u32, u32)>;
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct HeaderOrderMatch {
72 pub expected: Vec<String>,
74 pub observed: Vec<String>,
76 pub matched_positions: usize,
79 pub matched_set: usize,
82 pub reference_length: usize,
84 pub observed_length: usize,
86}
87
88impl HeaderOrderMatch {
89 #[must_use]
92 #[allow(clippy::cast_precision_loss)]
93 pub fn position_match_ratio(&self) -> f64 {
94 if self.reference_length == 0 {
95 return 0.0;
96 }
97 self.matched_positions as f64 / self.reference_length as f64
98 }
99
100 #[must_use]
103 #[allow(clippy::cast_precision_loss)]
104 pub fn set_match_ratio(&self) -> f64 {
105 if self.reference_length == 0 {
106 return 0.0;
107 }
108 self.matched_set as f64 / self.reference_length as f64
109 }
110}
111
112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
120pub struct TransportObservation {
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub http2_settings: Option<Http2SettingsObservation>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub http2_pseudo_header_order: Option<Vec<String>>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub http2_header_order: Option<Vec<String>>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub http3_perk_text: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub http3_perk_hash: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub alpn_protocols: Option<Vec<String>>,
141}
142
143impl TransportObservation {
144 #[must_use]
158 pub fn from_settings(settings: &[(u32, u32)]) -> Self {
159 Self {
160 http2_settings: Some(settings.to_vec()),
161 ..Self::default()
162 }
163 }
164
165 #[must_use]
167 pub fn with_pseudo_header_order<I, S>(mut self, order: I) -> Self
168 where
169 I: IntoIterator<Item = S>,
170 S: Into<String>,
171 {
172 self.http2_pseudo_header_order = Some(order.into_iter().map(Into::into).collect());
173 self
174 }
175
176 #[must_use]
178 pub fn with_header_order<I, S>(mut self, order: I) -> Self
179 where
180 I: IntoIterator<Item = S>,
181 S: Into<String>,
182 {
183 self.http2_header_order = Some(order.into_iter().map(Into::into).collect());
184 self
185 }
186
187 #[must_use]
189 pub fn with_http3_perk_text(mut self, text: impl Into<String>) -> Self {
190 self.http3_perk_text = Some(text.into());
191 self
192 }
193
194 #[must_use]
196 pub fn with_http3_perk_hash(mut self, hash: impl Into<String>) -> Self {
197 self.http3_perk_hash = Some(hash.into());
198 self
199 }
200
201 #[must_use]
203 pub fn with_alpn<I, S>(mut self, protocols: I) -> Self
204 where
205 I: IntoIterator<Item = S>,
206 S: Into<String>,
207 {
208 self.alpn_protocols = Some(protocols.into_iter().map(Into::into).collect());
209 self
210 }
211
212 #[must_use]
227 pub fn chrome_136_reference() -> Self {
228 Self {
229 http2_settings: Some(CHROME_136_HTTP2_SETTINGS.to_vec()),
230 http2_pseudo_header_order: Some(
231 PSEUDO_HEADER_ORDER_CHROME_136
232 .iter()
233 .map(|s| (*s).to_string())
234 .collect(),
235 ),
236 http2_header_order: Some(
237 HEADER_ORDER_CHROME_136
238 .iter()
239 .map(|s| (*s).to_string())
240 .collect(),
241 ),
242 http3_perk_text: None,
243 http3_perk_hash: None,
244 alpn_protocols: Some(vec!["h2".to_string(), "http/1.1".to_string()]),
245 }
246 }
247
248 #[must_use]
252 pub const fn has_http2(&self) -> bool {
253 self.http2_settings.is_some()
254 || self.http2_pseudo_header_order.is_some()
255 || self.http2_header_order.is_some()
256 }
257
258 #[must_use]
260 pub const fn http2_observation_count(&self) -> usize {
261 let mut n = 0;
262 if self.http2_settings.is_some() {
263 n += 1;
264 }
265 if self.http2_pseudo_header_order.is_some() {
266 n += 1;
267 }
268 if self.http2_header_order.is_some() {
269 n += 1;
270 }
271 n
272 }
273}
274
275#[must_use]
281pub fn compare_header_order(expected: &[&str], observed: &[String]) -> HeaderOrderMatch {
282 let expected_lc: Vec<String> = expected.iter().map(|s| s.to_ascii_lowercase()).collect();
283 let observed_lc: Vec<String> = observed.iter().map(|s| s.to_ascii_lowercase()).collect();
284
285 let matched_positions = expected_lc
286 .iter()
287 .zip(observed_lc.iter())
288 .filter(|(a, b)| a == b)
289 .count();
290 let matched_set = expected_lc
291 .iter()
292 .filter(|header| observed_lc.iter().any(|o| o == *header))
293 .count();
294
295 HeaderOrderMatch {
296 expected: expected_lc,
297 observed: observed_lc,
298 matched_positions,
299 matched_set,
300 reference_length: expected.len(),
301 observed_length: observed.len(),
302 }
303}
304
305#[must_use]
316pub fn compare_pseudo_header_order(observed: &[String]) -> HeaderOrderMatch {
317 compare_header_order(PSEUDO_HEADER_ORDER_CHROME_136, observed)
318}
319
320#[cfg(test)]
321#[allow(
322 clippy::unwrap_used,
323 clippy::expect_used,
324 clippy::panic,
325 clippy::indexing_slicing
326)]
327mod tests {
328 use super::*;
329 use crate::tls_validation::{CHROME_131_JA3, CHROME_136_HTTP2_SETTINGS};
330
331 #[test]
332 fn chrome_136_reference_seed_is_complete() {
333 let obs = TransportObservation::chrome_136_reference();
334 assert_eq!(
335 obs.http2_settings.as_deref(),
336 Some(CHROME_136_HTTP2_SETTINGS)
337 );
338 assert!(obs.has_http2());
339 assert_eq!(obs.http2_observation_count(), 3);
340 assert_eq!(
341 obs.http2_pseudo_header_order.as_deref(),
342 Some(PSEUDO_HEADER_ORDER_CHROME_136)
343 .map(|s| s.iter().map(|x| (*x).to_string()).collect::<Vec<_>>())
344 .as_ref()
345 .map(|v| &v[..])
346 );
347 }
348
349 #[test]
350 fn empty_observation_carries_no_http2_signal() {
351 let obs = TransportObservation::default();
352 assert!(!obs.has_http2());
353 assert_eq!(obs.http2_observation_count(), 0);
354 }
355
356 #[test]
357 fn header_order_position_match_counts_in_order_only() {
358 let expected = HEADER_ORDER_CHROME_136;
360 let observed: Vec<String> = vec![
361 "cookie".into(),
362 "accept-language".into(),
363 "host".into(),
364 "connection".into(),
365 ];
366 let m = compare_header_order(expected, &observed);
367 assert_eq!(m.matched_set, 4);
368 assert_eq!(m.matched_positions, 0);
369 assert!(m.position_match_ratio() < m.set_match_ratio());
370 }
371
372 #[test]
373 fn header_order_position_match_perfect_for_chrome_136() {
374 let expected = HEADER_ORDER_CHROME_136;
375 let observed: Vec<String> = expected.iter().map(|s| (*s).to_string()).collect();
376 let m = compare_header_order(expected, &observed);
377 assert_eq!(m.matched_positions, expected.len());
378 assert_eq!(m.matched_set, expected.len());
379 assert!((m.position_match_ratio() - 1.0).abs() < 1e-9);
380 }
381
382 #[test]
383 fn header_order_position_match_does_not_panic_on_empty_inputs() {
384 let m = compare_header_order(&[], &[]);
385 assert_eq!(m.matched_positions, 0);
386 assert_eq!(m.matched_set, 0);
387 assert_eq!(m.reference_length, 0);
388 let pos_ratio = m.position_match_ratio();
389 assert!(pos_ratio.abs() < 1e-9, "pos_ratio={pos_ratio}");
390 let set_ratio = m.set_match_ratio();
391 assert!(set_ratio.abs() < 1e-9, "set_ratio={set_ratio}");
392 }
393
394 #[test]
395 fn pseudo_header_order_matches_chrome_136() {
396 let observed: Vec<String> = PSEUDO_HEADER_ORDER_CHROME_136
397 .iter()
398 .map(|s| (*s).to_string())
399 .collect();
400 let m = compare_pseudo_header_order(&observed);
401 assert_eq!(m.matched_positions, PSEUDO_HEADER_ORDER_CHROME_136.len());
402 }
403
404 #[test]
405 fn from_settings_preserves_order_and_values() {
406 let obs = TransportObservation::from_settings(CHROME_136_HTTP2_SETTINGS);
407 let settings = obs.http2_settings.expect("settings");
408 assert_eq!(settings, CHROME_136_HTTP2_SETTINGS);
409 }
410
411 #[test]
412 fn builders_chain_and_preserve_previous_fields() {
413 let obs = TransportObservation::from_settings(CHROME_136_HTTP2_SETTINGS)
414 .with_header_order(HEADER_ORDER_CHROME_136.iter().copied())
415 .with_alpn(["h2", "http/1.1"]);
416 assert!(obs.http2_settings.is_some());
417 assert!(obs.http2_header_order.is_some());
418 assert_eq!(
419 obs.alpn_protocols.as_deref(),
420 Some(&["h2".to_string(), "http/1.1".to_string()][..])
421 );
422 }
423
424 #[test]
425 fn unused_constants_are_reachable() {
426 assert!(CHROME_131_JA3.len() == 32);
429 }
430}