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;