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