Skip to main content

stygian_browser/transport_realism/
observations.rs

1//! Observation types for the HTTP/2 behaviour checks.
2//!
3//! The [`TransportObservation`] type aggregates every per-connection
4//! transport fingerprint the [`score`][crate::transport_realism::score]
5//! function can consume. Each field is optional so callers can
6//! supply only the observations they actually captured; missing
7//! observations are reflected in the
8//! [`TransportCompatibility::coverage`][crate::transport_realism::TransportCompatibility::coverage]
9//! marker.
10
11use serde::{Deserialize, Serialize};
12
13use crate::tls_validation::CHROME_136_HTTP2_SETTINGS;
14
15/// Ordered list of HTTP/2 header names Chrome 136 sends on a
16/// standard navigation (after the `:method`, `:authority`, `:scheme`,
17/// `:path` pseudo-headers).
18///
19/// Order is observable by the server and forms part of the Akamai /
20/// `DataDome` fingerprint.
21pub 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
38/// Ordered list of HTTP/2 header names Firefox 130 sends on a
39/// standard navigation (after the pseudo-headers).
40///
41/// Order differs from Chrome in two places (`user-agent` and
42/// `cookie` are last in Firefox but interleaved in Chrome).
43pub 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
57/// Expected HTTP/2 pseudo-header order for Chrome 136.
58///
59/// HTTP/2 requires pseudo-headers to appear before regular headers;
60/// Chrome 136 sends them in a stable, observable order.
61pub const PSEUDO_HEADER_ORDER_CHROME_136: &[&str] = &[":method", ":authority", ":scheme", ":path"];
62
63/// HTTP/2 SETTINGS frame fingerprint captured from a live connection.
64///
65/// The tuple mirrors the `(id, value)` layout produced by the
66/// existing [`crate::tls_validation::compare_http2_settings`] helper.
67pub type Http2SettingsObservation = Vec<(u32, u32)>;
68
69/// Result of comparing an observed header order against a reference.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct HeaderOrderMatch {
72    /// Reference header order the observation was compared against.
73    pub expected: Vec<String>,
74    /// Header order the caller observed.
75    pub observed: Vec<String>,
76    /// Number of headers in the observed order that appear in the
77    /// reference order **at the same position**.
78    pub matched_positions: usize,
79    /// Number of headers in the reference order that appear anywhere
80    /// in the observed order.
81    pub matched_set: usize,
82    /// Total headers in the reference order.
83    pub reference_length: usize,
84    /// Total headers in the observed order.
85    pub observed_length: usize,
86}
87
88impl HeaderOrderMatch {
89    /// Position-match ratio in `[0.0, 1.0]`. Returns `0.0` for an
90    /// empty reference (avoids NaN from 0/0).
91    #[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    /// Set-match ratio in `[0.0, 1.0]`. Returns `0.0` for an empty
101    /// reference.
102    #[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/// Live transport-layer observations consumed by
113/// [`score`][crate::transport_realism::score].
114///
115/// Every field is optional so callers can supply only the
116/// observations they actually captured. Missing observations are
117/// surfaced in the [`TransportCompatibility::coverage`][crate::transport_realism::TransportCompatibility::coverage]
118/// marker.
119#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
120pub struct TransportObservation {
121    /// Observed HTTP/2 SETTINGS frame.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub http2_settings: Option<Http2SettingsObservation>,
124    /// Observed HTTP/2 pseudo-header order (e.g. `:method`,
125    /// `:authority`, `:scheme`, `:path`).
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub http2_pseudo_header_order: Option<Vec<String>>,
128    /// Observed HTTP/2 header order (after pseudo-headers).
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub http2_header_order: Option<Vec<String>>,
131    /// HTTP/3 perk text observed from the live connection (already
132    /// consumed by `tls_validation::TransportDiagnostic`).
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub http3_perk_text: Option<String>,
135    /// HTTP/3 perk hash observed from the live connection.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub http3_perk_hash: Option<String>,
138    /// Observed ALPN protocol list (e.g. `["h2", "http/1.1"]`).
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub alpn_protocols: Option<Vec<String>>,
141}
142
143impl TransportObservation {
144    /// Build an observation seeded with the supplied HTTP/2 SETTINGS
145    /// frame. Used by the unit tests and the integration tests that
146    /// compare against the `tls_validation` reference captures.
147    ///
148    /// # Example
149    ///
150    /// ```
151    /// use stygian_browser::tls_validation::CHROME_136_HTTP2_SETTINGS;
152    /// use stygian_browser::transport_realism::TransportObservation;
153    ///
154    /// let obs = TransportObservation::from_settings(CHROME_136_HTTP2_SETTINGS);
155    /// assert!(obs.http2_settings.is_some());
156    /// ```
157    #[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    /// Attach a pseudo-header order observation.
166    #[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    /// Attach a header order observation.
177    #[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    /// Attach a single HTTP/3 perk text observation.
188    #[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    /// Attach a single HTTP/3 perk hash observation.
195    #[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    /// Attach an ALPN protocol list observation.
202    #[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    /// Convenience: build an observation that exactly matches the
213    /// Chrome 136 reference captures.
214    ///
215    /// # Example
216    ///
217    /// ```
218    /// use stygian_browser::transport_realism::TransportObservation;
219    ///
220    /// let obs = TransportObservation::chrome_136_reference();
221    /// assert!(obs.http2_settings.is_some());
222    /// assert!(obs.http2_header_order.is_some());
223    /// assert!(obs.http2_pseudo_header_order.is_some());
224    /// assert!(obs.alpn_protocols.is_some());
225    /// ```
226    #[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    /// `true` when the observation carries any HTTP/2 surface
249    /// (`http2_settings`, `http2_pseudo_header_order`, or
250    /// `http2_header_order`).
251    #[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    /// Number of HTTP/2 observations that were supplied.
259    #[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/// Compare an observed header order against a reference header
276/// order, returning a structured [`HeaderOrderMatch`].
277///
278/// Both lists are lower-cased before comparison so casing mismatches
279/// don't inflate the position-count mismatch list.
280#[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/// Compare an observed pseudo-header order against the Chrome 136
306/// reference.
307///
308/// The reference is the only stable observation we have
309/// for pseudo-headers; mismatches fall into "wrong order"
310/// rather than "wrong set" because the set is fixed by the
311/// HTTP/2 spec.
312///
313/// Exposed for callers that want to reuse the same matcher the
314/// scoring logic uses.
315#[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        // Swapped order should drop position matches but keep set matches.
359        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        // Reference capture constants must remain reachable so other
427        // modules (diagnostic.rs, tls_validation.rs) can keep using them.
428        assert!(CHROME_131_JA3.len() == 32);
429    }
430}