Skip to main content

stygian_browser/transport_realism/
profile.rs

1//! Per-target transport profile.
2//!
3//! [`TransportProfile`] is the typed config field callers attach to
4//! an [`AcquisitionRequest`][crate::acquisition::AcquisitionRequest]
5//! to express which HTTP/2 surfaces they want the runner to compare
6//! against. The profile is fully serialisable so it can travel in
7//! config files / session snapshots without bespoke glue.
8
9use serde::{Deserialize, Serialize};
10
11use crate::tls_validation::CHROME_136_HTTP2_SETTINGS;
12
13use super::observations::{HEADER_ORDER_CHROME_136, PSEUDO_HEADER_ORDER_CHROME_136};
14
15/// HTTP/2 expectations as a compact bitmask.
16///
17/// The runner compares the supplied observation against the profile
18/// only for the checks whose bit is set. Encoding expectations as a
19/// `u8` bitmask keeps [`TransportProfile`] free of
20/// `clippy::struct_excessive_bools` lints — three separate `bool`
21/// fields would otherwise trip the lint even though each flag is
22/// independently meaningful.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24#[serde(transparent)]
25pub struct Http2Expectations {
26    /// Bitmask: bit 0 = settings, bit 1 = pseudo-header order,
27    /// bit 2 = header order.
28    bits: u8,
29}
30
31impl Http2Expectations {
32    /// Settings-frame fingerprint comparison is enabled.
33    pub const SETTINGS: u8 = 1 << 0;
34    /// Pseudo-header order comparison is enabled.
35    pub const PSEUDO_HEADER_ORDER: u8 = 1 << 1;
36    /// Regular header order comparison is enabled.
37    pub const HEADER_ORDER: u8 = 1 << 2;
38    /// All three expectations enabled (default profile).
39    pub const ALL: u8 = Self::SETTINGS | Self::PSEUDO_HEADER_ORDER | Self::HEADER_ORDER;
40
41    /// Build a bitmask from raw flag bits.
42    #[must_use]
43    pub const fn from_bits(bits: u8) -> Self {
44        Self { bits }
45    }
46
47    /// `true` when no expectations are enabled.
48    #[must_use]
49    pub const fn is_empty(self) -> bool {
50        self.bits == 0
51    }
52
53    /// `true` when the supplied `flag` bit is set.
54    #[must_use]
55    pub const fn contains(self, flag: u8) -> bool {
56        (self.bits & flag) == flag
57    }
58
59    /// Number of expectation bits enabled.
60    #[must_use]
61    pub const fn count(self) -> usize {
62        (self.bits & Self::SETTINGS != 0) as usize
63            + (self.bits & Self::PSEUDO_HEADER_ORDER != 0) as usize
64            + (self.bits & Self::HEADER_ORDER != 0) as usize
65    }
66}
67
68impl Default for Http2Expectations {
69    fn default() -> Self {
70        Self { bits: Self::ALL }
71    }
72}
73
74/// Per-target HTTP/2 transport expectations.
75///
76/// A [`TransportProfile`] carries the reference fingerprints the
77/// [`score`][crate::transport_realism::score] function compares the
78/// [`TransportObservation`][crate::transport_realism::TransportObservation]
79/// against. When the runner is asked to evaluate a request, the
80/// profile travels with the [`AcquisitionRequest`][crate::acquisition::AcquisitionRequest]
81/// and the resulting [`TransportCompatibility`][crate::transport_realism::TransportCompatibility]
82/// is attached to the
83/// [`AcquisitionResult::transport_realism`][crate::acquisition::AcquisitionResult::transport_realism]
84/// field as a strategy hint for downstream policy mapping (T83 / T85
85/// / T89 / T93).
86///
87/// # Example
88///
89/// ```
90/// use stygian_browser::transport_realism::TransportProfile;
91///
92/// let profile = TransportProfile::chrome_136_reference();
93/// assert!(profile.expectations.contains(TransportProfile::SETTINGS));
94/// assert_eq!(profile.expected_http2_settings.len(), 5);
95/// ```
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct TransportProfile {
98    /// Logical profile name (e.g. `"chrome-136"`, `"firefox-130"`).
99    /// Free-form: callers can use it to label the profile in
100    /// telemetry.
101    #[serde(default = "default_profile_name")]
102    pub name: String,
103    /// Reference HTTP/2 SETTINGS frame fingerprint.
104    #[serde(default = "default_http2_settings")]
105    pub expected_http2_settings: Vec<(u32, u32)>,
106    /// Reference HTTP/2 pseudo-header order.
107    #[serde(default = "default_pseudo_header_order")]
108    pub expected_pseudo_header_order: Vec<String>,
109    /// Reference HTTP/2 header order (after pseudo-headers).
110    #[serde(default = "default_header_order")]
111    pub expected_header_order: Vec<String>,
112    /// HTTP/2 expectations bitmask (settings / pseudo-header order /
113    /// header order).
114    #[serde(default)]
115    pub expectations: Http2Expectations,
116    /// When `true`, the runner rejects profiles with no HTTP/2
117    /// observations as `incompatible` (used to detect partial
118    /// instrumentation in hostile-target acquisition paths).
119    #[serde(default)]
120    pub require_http2_observations: bool,
121}
122
123impl TransportProfile {
124    /// Re-export of [`Http2Expectations::SETTINGS`] so callers can
125    /// write `profile.expectations.contains(TransportProfile::SETTINGS)`.
126    pub const SETTINGS: u8 = Http2Expectations::SETTINGS;
127    /// Re-export of [`Http2Expectations::PSEUDO_HEADER_ORDER`].
128    pub const PSEUDO_HEADER_ORDER: u8 = Http2Expectations::PSEUDO_HEADER_ORDER;
129    /// Re-export of [`Http2Expectations::HEADER_ORDER`].
130    pub const HEADER_ORDER: u8 = Http2Expectations::HEADER_ORDER;
131}
132
133fn default_profile_name() -> String {
134    "chrome-136".to_string()
135}
136
137fn default_http2_settings() -> Vec<(u32, u32)> {
138    CHROME_136_HTTP2_SETTINGS.to_vec()
139}
140
141fn default_pseudo_header_order() -> Vec<String> {
142    PSEUDO_HEADER_ORDER_CHROME_136
143        .iter()
144        .map(|s| (*s).to_string())
145        .collect()
146}
147
148fn default_header_order() -> Vec<String> {
149    HEADER_ORDER_CHROME_136
150        .iter()
151        .map(|s| (*s).to_string())
152        .collect()
153}
154
155impl Default for TransportProfile {
156    fn default() -> Self {
157        Self {
158            name: default_profile_name(),
159            expected_http2_settings: default_http2_settings(),
160            expected_pseudo_header_order: default_pseudo_header_order(),
161            expected_header_order: default_header_order(),
162            expectations: Http2Expectations::default(),
163            require_http2_observations: false,
164        }
165    }
166}
167
168impl TransportProfile {
169    /// Build a profile whose references match the Chrome 136 capture.
170    ///
171    /// Convenience constructor for tests and the Chrome 136 default
172    /// path. The behaviour is identical to
173    /// [`TransportProfile::default`].
174    #[must_use]
175    pub fn chrome_136_reference() -> Self {
176        Self::default()
177    }
178
179    /// Replace the profile name.
180    #[must_use]
181    pub fn with_name(mut self, name: impl Into<String>) -> Self {
182        self.name = name.into();
183        self
184    }
185
186    /// Replace the HTTP/2 SETTINGS reference.
187    #[must_use]
188    pub fn with_http2_settings(mut self, settings: Vec<(u32, u32)>) -> Self {
189        self.expected_http2_settings = settings;
190        self
191    }
192
193    /// Replace the HTTP/2 pseudo-header order reference.
194    #[must_use]
195    pub fn with_pseudo_header_order(mut self, order: Vec<String>) -> Self {
196        self.expected_pseudo_header_order = order;
197        self
198    }
199
200    /// Replace the HTTP/2 header order reference.
201    #[must_use]
202    pub fn with_header_order(mut self, order: Vec<String>) -> Self {
203        self.expected_header_order = order;
204        self
205    }
206
207    /// Toggle the `require_http2_observations` flag.
208    #[must_use]
209    pub const fn with_require_http2_observations(mut self, require: bool) -> Self {
210        self.require_http2_observations = require;
211        self
212    }
213
214    /// Replace the expectations bitmask wholesale.
215    #[must_use]
216    pub const fn with_expectations(mut self, expectations: Http2Expectations) -> Self {
217        self.expectations = expectations;
218        self
219    }
220
221    /// Replace the expectations bitmask from raw flag bits.
222    #[must_use]
223    pub const fn with_expectation_bits(mut self, bits: u8) -> Self {
224        self.expectations = Http2Expectations::from_bits(bits);
225        self
226    }
227
228    /// `true` when at least one HTTP/2 expectation is enabled.
229    #[must_use]
230    pub const fn has_any_http2_expectation(&self) -> bool {
231        !self.expectations.is_empty()
232    }
233
234    /// Number of HTTP/2 expectations enabled.
235    #[must_use]
236    pub const fn expected_http2_check_count(&self) -> usize {
237        self.expectations.count()
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::tls_validation::CHROME_136_HTTP2_SETTINGS;
245
246    #[test]
247    fn default_profile_matches_chrome_136() {
248        let profile = TransportProfile::default();
249        assert_eq!(profile.name, "chrome-136");
250        assert_eq!(profile.expected_http2_settings, CHROME_136_HTTP2_SETTINGS);
251        assert!(profile.has_any_http2_expectation());
252        assert_eq!(profile.expected_http2_check_count(), 3);
253        assert!(!profile.require_http2_observations);
254        assert!(profile.expectations.contains(TransportProfile::SETTINGS));
255        assert!(
256            profile
257                .expectations
258                .contains(TransportProfile::PSEUDO_HEADER_ORDER)
259        );
260        assert!(
261            profile
262                .expectations
263                .contains(TransportProfile::HEADER_ORDER)
264        );
265    }
266
267    #[test]
268    fn chrome_136_reference_matches_default() {
269        assert_eq!(
270            TransportProfile::chrome_136_reference(),
271            TransportProfile::default()
272        );
273    }
274
275    #[test]
276    fn with_name_replaces_name_only() {
277        let profile = TransportProfile::default().with_name("firefox-130");
278        assert_eq!(profile.name, "firefox-130");
279        assert!(profile.has_any_http2_expectation());
280    }
281
282    #[test]
283    fn with_expectations_toggles_all_three_flags() {
284        let profile = TransportProfile::default().with_expectation_bits(0);
285        assert!(!profile.has_any_http2_expectation());
286        assert_eq!(profile.expected_http2_check_count(), 0);
287    }
288
289    #[test]
290    fn require_http2_observations_round_trips_via_serde()
291    -> std::result::Result<(), Box<dyn std::error::Error>> {
292        let profile = TransportProfile::default().with_require_http2_observations(true);
293        let json = serde_json::to_string(&profile)?;
294        let back: TransportProfile = serde_json::from_str(&json)?;
295        assert_eq!(profile, back);
296        Ok(())
297    }
298
299    #[test]
300    fn json_round_trip_default_profile() -> std::result::Result<(), Box<dyn std::error::Error>> {
301        let p = TransportProfile::default();
302        let json = serde_json::to_string(&p)?;
303        let back: TransportProfile = serde_json::from_str(&json)?;
304        assert_eq!(p, back);
305        Ok(())
306    }
307
308    #[test]
309    fn expectations_bitmask_count_matches_set_bits() {
310        let empty = Http2Expectations::from_bits(0);
311        assert!(empty.is_empty());
312        assert_eq!(empty.count(), 0);
313
314        let settings_only = Http2Expectations::from_bits(TransportProfile::SETTINGS);
315        assert_eq!(settings_only.count(), 1);
316        assert!(settings_only.contains(TransportProfile::SETTINGS));
317
318        let all = Http2Expectations::from_bits(Http2Expectations::ALL);
319        assert_eq!(all.count(), 3);
320    }
321}