Skip to main content

leyline/profile/
types.rs

1use std::collections::HashMap;
2
3use serde::Deserialize;
4
5use crate::profile::H3Profile;
6use crate::profile::preset::HeaderStyle;
7use crate::profile::registry::ProfileError;
8use crate::{Error, Kind};
9
10#[expect(
11    missing_docs,
12    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
13)]
14#[derive(Debug, Clone, Deserialize)]
15#[serde(deny_unknown_fields)]
16#[non_exhaustive]
17pub struct BrowserProfile {
18    pub meta: ProfileMeta,
19    pub tls: TlsProfile,
20    pub h2: H2Profile,
21    #[serde(default)]
22    pub identity: HashMap<String, PlatformIdentity>,
23    #[serde(default)]
24    pub h3: Option<H3Profile>,
25    #[serde(skip)]
26    source: Option<source::ProfileSource>,
27}
28
29#[expect(
30    missing_docs,
31    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
32)]
33#[derive(Debug, Clone, Deserialize)]
34#[serde(deny_unknown_fields)]
35#[non_exhaustive]
36pub struct ProfileMeta {
37    pub name: String,
38    pub browser: String,
39    pub version: u32,
40    #[serde(default)]
41    pub family: String,
42    #[serde(default)]
43    pub verified_against: String,
44    #[serde(default)]
45    pub captured_against: Option<String>,
46    #[serde(default)]
47    pub chromium_major: Option<u32>,
48    #[serde(default)]
49    pub ch_ua_brand: Option<String>,
50    #[serde(default)]
51    pub header_style: HeaderStyle,
52    #[serde(skip)]
53    pub(crate) header_order: Option<Vec<String>>,
54    #[serde(default, rename = "capture")]
55    _capture: serde::de::IgnoredAny,
56    #[serde(default, rename = "variant")]
57    _variant: serde::de::IgnoredAny,
58    #[serde(default, rename = "hello")]
59    _hello: serde::de::IgnoredAny,
60    #[serde(default, rename = "platform_browser")]
61    _platform_browser: serde::de::IgnoredAny,
62    #[serde(default, rename = "deprecated")]
63    _deprecated: serde::de::IgnoredAny,
64    #[serde(default, rename = "verified_at")]
65    _verified_at: serde::de::IgnoredAny,
66}
67
68#[expect(
69    missing_docs,
70    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
71)]
72#[derive(Debug, Clone, Deserialize)]
73#[serde(deny_unknown_fields)]
74#[non_exhaustive]
75pub struct TlsProfile {
76    pub ciphers: Vec<String>,
77    pub curves: Vec<String>,
78    #[serde(default)]
79    pub key_shares: Option<Vec<String>>,
80    pub sigalgs: Vec<String>,
81    #[serde(default)]
82    pub delegated_credentials: Option<String>,
83    #[serde(default)]
84    pub record_size_limit: Option<u16>,
85    #[serde(default)]
86    pub alps: Option<String>,
87    #[serde(default)]
88    pub alps_new_codepoint: bool,
89    #[serde(default)]
90    pub ocsp_stapling: bool,
91    #[serde(default)]
92    pub signed_cert_timestamps: bool,
93    #[serde(default)]
94    pub cert_compression: Vec<String>,
95    #[serde(default)]
96    pub permute_extensions: bool,
97    #[serde(default)]
98    pub extension_permutation: Option<Vec<u16>>,
99    #[serde(default)]
100    pub extension_tail: Vec<u16>,
101    #[serde(default = "default_grease")]
102    pub grease: bool,
103    #[serde(default)]
104    pub sigalg_grease: bool,
105    #[serde(default)]
106    pub ech_grease: bool,
107    #[serde(default)]
108    pub pre_shared_key: bool,
109    #[serde(default = "default_true")]
110    pub session_tickets: bool,
111    #[serde(default)]
112    pub request_trust_anchors: bool,
113    #[serde(default)]
114    pub fingerprint: Option<TlsFingerprint>,
115    #[serde(default)]
116    pub padding: bool,
117    #[serde(default)]
118    pub min_tls_version: Option<String>,
119    #[serde(default)]
120    pub tls12_extensions: bool,
121}
122
123const fn default_grease() -> bool {
124    true
125}
126
127const fn default_true() -> bool {
128    true
129}
130
131#[expect(
132    missing_docs,
133    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
134)]
135#[derive(Debug, Clone, Default, Deserialize)]
136#[serde(deny_unknown_fields)]
137#[non_exhaustive]
138pub struct TlsFingerprint {
139    #[serde(default)]
140    pub ja4: Option<String>,
141    #[serde(default)]
142    pub resumed_ja4: Option<String>,
143    #[serde(default)]
144    pub platforms: HashMap<String, TlsFingerprint>,
145}
146
147#[expect(
148    missing_docs,
149    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
150)]
151#[derive(Debug, Clone, Deserialize)]
152#[serde(deny_unknown_fields)]
153#[non_exhaustive]
154pub struct H2Profile {
155    #[serde(default)]
156    pub header_table_size: Option<u32>,
157    #[serde(default)]
158    pub enable_push: Option<bool>,
159    #[serde(default)]
160    pub max_concurrent_streams: Option<u32>,
161    #[serde(default)]
162    pub initial_stream_window_size: Option<u32>,
163    #[serde(default)]
164    pub initial_connection_window_size: Option<u32>,
165    #[serde(default)]
166    pub max_frame_size: Option<u32>,
167    #[serde(default)]
168    pub max_header_list_size: Option<u32>,
169    #[serde(default)]
170    pub unknown_setting8: Option<u32>,
171    #[serde(default)]
172    pub unknown_setting9: Option<u32>,
173    pub pseudo_order: Vec<String>,
174    pub settings_order: Vec<String>,
175    #[serde(default)]
176    pub default_priority: Option<H2PriorityProfile>,
177    #[serde(default)]
178    pub fingerprint: Option<H2Fingerprint>,
179    #[serde(default)]
180    pub platforms: HashMap<String, H2PlatformOverride>,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
184#[serde(deny_unknown_fields)]
185#[non_exhaustive]
186pub struct H2PriorityProfile {
187    pub exclusive: bool,
188    pub stream_dependency: u32,
189    pub weight: u8,
190}
191
192#[expect(
193    missing_docs,
194    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
195)]
196#[derive(Debug, Clone, Default, Deserialize)]
197#[serde(deny_unknown_fields)]
198#[non_exhaustive]
199pub struct H2PlatformOverride {
200    #[serde(default)]
201    pub header_table_size: Option<u32>,
202    #[serde(default)]
203    pub enable_push: Option<bool>,
204    #[serde(default)]
205    pub max_concurrent_streams: Option<u32>,
206    #[serde(default)]
207    pub initial_stream_window_size: Option<u32>,
208    #[serde(default)]
209    pub initial_connection_window_size: Option<u32>,
210    #[serde(default)]
211    pub max_frame_size: Option<u32>,
212    #[serde(default)]
213    pub max_header_list_size: Option<u32>,
214    #[serde(default)]
215    pub unknown_setting8: Option<u32>,
216    #[serde(default)]
217    pub unknown_setting9: Option<u32>,
218    #[serde(default)]
219    pub omit_settings: Vec<String>,
220    #[serde(default)]
221    pub pseudo_order: Option<Vec<String>>,
222    #[serde(default)]
223    pub settings_order: Option<Vec<String>>,
224    #[serde(default)]
225    pub fingerprint: Option<H2Fingerprint>,
226}
227
228#[expect(
229    missing_docs,
230    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
231)]
232#[derive(Debug, Clone, Default, Deserialize)]
233#[serde(deny_unknown_fields)]
234#[non_exhaustive]
235pub struct H2Fingerprint {
236    #[serde(default)]
237    pub akamai: Option<String>,
238}
239
240#[expect(
241    missing_docs,
242    reason = "profile schema mirrors the embedded TOML tables; variant and field names are the documentation"
243)]
244#[derive(Debug, Clone, Deserialize)]
245#[serde(deny_unknown_fields)]
246#[non_exhaustive]
247pub struct PlatformIdentity {
248    pub user_agent: String,
249    #[serde(default)]
250    pub sec_ch_ua: String,
251    #[serde(default)]
252    pub accept_language: Option<String>,
253}
254
255impl BrowserProfile {
256    pub fn from_toml(toml_str: &str) -> Result<Self, ProfileError> {
257        let mut profile: Self = toml::from_str(toml_str).map_err(ProfileError::parse)?;
258        profile.derive_sec_ch_ua();
259        profile.source = Some(source::ProfileSource::new(toml_str));
260        crate::profile::permutation::validate(&profile.tls, false)
261            .map_err(|why| ProfileError::parse(format!("{}: {why}", profile.meta.name)))?;
262        if let Some(h3) = &profile.h3 {
263            h3.validate()
264                .map_err(|why| ProfileError::parse(format!("{}: {why}", profile.meta.name)))?;
265        }
266        for warning in profile.load_warnings() {
267            tracing::warn!(target: "leyline::profile", "{warning}");
268        }
269        Ok(profile)
270    }
271
272    fn derive_sec_ch_ua(&mut self) {
273        let Some(brand) = self.meta.ch_ua_brand.as_deref() else {
274            return;
275        };
276        let derived =
277            crate::profile::brand::sec_ch_ua(self.meta.version, Some((brand, self.meta.version)));
278        for identity in self.identity.values_mut() {
279            if identity.sec_ch_ua.is_empty() {
280                identity.sec_ch_ua.clone_from(&derived);
281            }
282        }
283    }
284
285    bench_pub! {
286        fn load_warnings(&self) -> Vec<String> {
287            let mut warnings = Vec::new();
288            let capture_unrecorded = self
289                .meta
290                .captured_against
291                .as_deref()
292                .is_none_or(|s| s.trim().is_empty());
293            if capture_unrecorded {
294                warnings.push(format!(
295                    "{}: [meta] captured_against is missing — the exact browser build this \
296                     profile was captured against is unrecorded",
297                    self.meta.name
298                ));
299            }
300            warnings
301        }
302    }
303
304    bench_pub! {
305        fn identity_for(&self, platform: crate::profile::Platform) -> Option<&PlatformIdentity> {
306            self.identity.get(platform.identity_key())
307        }
308    }
309
310    pub(crate) fn source(&self) -> Option<&str> {
311        self.source.as_ref().map(source::ProfileSource::as_str)
312    }
313
314    pub fn expected_ja4(&self) -> Option<&str> {
315        self.tls.fingerprint.as_ref()?.ja4.as_deref()
316    }
317
318    pub fn expected_h2_fingerprint(&self) -> Option<&str> {
319        self.h2.fingerprint.as_ref()?.akamai.as_deref()
320    }
321}
322
323impl H2Profile {
324    bench_pub! {
325        fn resolve_for_platform(
326            &self,
327            platform: crate::profile::Platform,
328        ) -> Result<H2Profile, Error> {
329            let Some(over) = self.platforms.get(platform.identity_key()) else {
330                return Ok(self.clone());
331            };
332            let mut out = self.clone();
333
334            if let Some(v) = over.header_table_size {
335                out.header_table_size = Some(v);
336            }
337            if let Some(v) = over.enable_push {
338                out.enable_push = Some(v);
339            }
340            if let Some(v) = over.max_concurrent_streams {
341                out.max_concurrent_streams = Some(v);
342            }
343            if let Some(v) = over.initial_stream_window_size {
344                out.initial_stream_window_size = Some(v);
345            }
346            if let Some(v) = over.initial_connection_window_size {
347                out.initial_connection_window_size = Some(v);
348            }
349            if let Some(v) = over.max_frame_size {
350                out.max_frame_size = Some(v);
351            }
352            if let Some(v) = over.max_header_list_size {
353                out.max_header_list_size = Some(v);
354            }
355            if let Some(v) = over.unknown_setting8 {
356                out.unknown_setting8 = Some(v);
357            }
358            if let Some(v) = over.unknown_setting9 {
359                out.unknown_setting9 = Some(v);
360            }
361            if let Some(ref order) = over.pseudo_order {
362                out.pseudo_order = order.clone();
363            }
364            if let Some(ref order) = over.settings_order {
365                out.settings_order = order.clone();
366            }
367
368            for name in &over.omit_settings {
369                match name.as_str() {
370                    "header_table_size" => out.header_table_size = None,
371                    "enable_push" => out.enable_push = None,
372                    "max_concurrent_streams" => out.max_concurrent_streams = None,
373                    "initial_stream_window_size" => out.initial_stream_window_size = None,
374                    "max_frame_size" => out.max_frame_size = None,
375                    "max_header_list_size" => out.max_header_list_size = None,
376                    "unknown_setting8" => out.unknown_setting8 = None,
377                    "unknown_setting9" => out.unknown_setting9 = None,
378                    other => {
379                        return Err(Error::new(Kind::Config).with_message(format!(
380                            "unknown name in [h2.platforms.{}].omit_settings: {other:?}",
381                            platform.identity_key()
382                        )));
383                    }
384                }
385            }
386
387            Ok(out)
388        }
389    }
390}
391
392mod source;
393
394#[cfg(test)]
395mod tests;