Skip to main content

stygian_browser/
tls.rs

1//! TLS fingerprint profile types with JA3/JA4 representation.
2//!
3//! ALPN preferences that match genuine browsers.
4//!
5//! # Built-in profiles
6//!
7//! Four static profiles ship with real-world TLS parameters:
8//!
9//! | Profile | Browser |
10//! |---|---|
11//! | [`CHROME_131`] | Google Chrome 131 |
12//! | [`FIREFOX_133`] | Mozilla Firefox 133 |
13//! | [`SAFARI_18`] | Apple Safari 18 |
14//! | [`EDGE_131`] | Microsoft Edge 131 |
15//!
16//! # Example
17//!
18//! ```
19//! use stygian_browser::tls::{CHROME_131, TlsProfile};
20//!
21//! let profile: &TlsProfile = &*CHROME_131;
22//! assert_eq!(profile.name, "Chrome 131");
23//!
24//! let ja3 = profile.ja3();
25//! assert!(!ja3.raw.is_empty());
26//! assert!(!ja3.hash.is_empty());
27//!
28//! let ja4 = profile.ja4();
29//! assert!(ja4.fingerprint.starts_with("t13"));
30//! ```
31
32use serde::{Deserialize, Serialize};
33use std::fmt;
34use std::sync::LazyLock;
35
36// ── entropy helper ───────────────────────────────────────────────────────────
37
38/// Splitmix64-style hash — mixes `seed` with a `step` multiplier so every
39/// call with a unique `step` produces an independent random-looking value.
40pub(crate) const fn rng(seed: u64, step: u64) -> u64 {
41    let x = seed.wrapping_add(step.wrapping_mul(0x9e37_79b9_7f4a_7c15));
42    let x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
43    let x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
44    x ^ (x >> 31)
45}
46
47// ── newtype wrappers ─────────────────────────────────────────────────────────
48
49/// TLS cipher-suite identifier (IANA two-byte code point).
50///
51/// Order within a [`TlsProfile`] matters — anti-bot systems compare the
52/// ordering against known browser fingerprints.
53///
54/// # Example
55///
56/// ```
57/// use stygian_browser::tls::CipherSuiteId;
58///
59/// let aes128 = CipherSuiteId::TLS_AES_128_GCM_SHA256;
60/// assert_eq!(aes128.0, 0x1301);
61/// ```
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63pub struct CipherSuiteId(pub u16);
64
65impl CipherSuiteId {
66    /// TLS 1.3 — AES-128-GCM with SHA-256.
67    pub const TLS_AES_128_GCM_SHA256: Self = Self(0x1301);
68    /// TLS 1.3 — AES-256-GCM with SHA-384.
69    pub const TLS_AES_256_GCM_SHA384: Self = Self(0x1302);
70    /// TLS 1.3 — ChaCha20-Poly1305 with SHA-256.
71    pub const TLS_CHACHA20_POLY1305_SHA256: Self = Self(0x1303);
72    /// TLS 1.2 — ECDHE-ECDSA-AES128-GCM-SHA256.
73    pub const TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: Self = Self(0xc02b);
74    /// TLS 1.2 — ECDHE-RSA-AES128-GCM-SHA256.
75    pub const TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: Self = Self(0xc02f);
76    /// TLS 1.2 — ECDHE-ECDSA-AES256-GCM-SHA384.
77    pub const TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: Self = Self(0xc02c);
78    /// TLS 1.2 — ECDHE-RSA-AES256-GCM-SHA384.
79    pub const TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: Self = Self(0xc030);
80    /// TLS 1.2 — ECDHE-ECDSA-CHACHA20-POLY1305.
81    pub const TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: Self = Self(0xcca9);
82    /// TLS 1.2 — ECDHE-RSA-CHACHA20-POLY1305.
83    pub const TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256: Self = Self(0xcca8);
84    /// TLS 1.2 — ECDHE-RSA-AES128-SHA.
85    pub const TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: Self = Self(0xc013);
86    /// TLS 1.2 — ECDHE-RSA-AES256-SHA.
87    pub const TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: Self = Self(0xc014);
88    /// TLS 1.2 — RSA-AES128-GCM-SHA256.
89    pub const TLS_RSA_WITH_AES_128_GCM_SHA256: Self = Self(0x009c);
90    /// TLS 1.2 — RSA-AES256-GCM-SHA384.
91    pub const TLS_RSA_WITH_AES_256_GCM_SHA384: Self = Self(0x009d);
92    /// TLS 1.2 — RSA-AES128-SHA.
93    pub const TLS_RSA_WITH_AES_128_CBC_SHA: Self = Self(0x002f);
94    /// TLS 1.2 — RSA-AES256-SHA.
95    pub const TLS_RSA_WITH_AES_256_CBC_SHA: Self = Self(0x0035);
96}
97
98impl fmt::Display for CipherSuiteId {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        write!(f, "{}", self.0)
101    }
102}
103
104///
105/// # Example
106///
107/// ```
108/// use stygian_browser::tls::TlsVersion;
109///
110/// let v = TlsVersion::Tls13;
111/// assert_eq!(v.iana_value(), 0x0304);
112/// ```
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
114#[non_exhaustive]
115pub enum TlsVersion {
116    /// TLS 1.2 (0x0303).
117    Tls12,
118    /// TLS 1.3 (0x0304).
119    Tls13,
120}
121
122impl TlsVersion {
123    ///
124    /// # Example
125    ///
126    /// ```
127    /// use stygian_browser::tls::TlsVersion;
128    ///
129    /// assert_eq!(TlsVersion::Tls12.iana_value(), 0x0303);
130    /// ```
131    pub const fn iana_value(self) -> u16 {
132        match self {
133            Self::Tls12 => 0x0303,
134            Self::Tls13 => 0x0304,
135        }
136    }
137}
138
139impl fmt::Display for TlsVersion {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        write!(f, "{}", self.iana_value())
142    }
143}
144
145/// TLS extension identifier (IANA two-byte code point).
146///
147/// # Example
148///
149/// ```
150/// use stygian_browser::tls::TlsExtensionId;
151///
152/// let sni = TlsExtensionId::SERVER_NAME;
153/// assert_eq!(sni.0, 0);
154/// ```
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
156pub struct TlsExtensionId(pub u16);
157
158impl TlsExtensionId {
159    /// `server_name` (SNI).
160    pub const SERVER_NAME: Self = Self(0);
161    /// `extended_master_secret`.
162    pub const EXTENDED_MASTER_SECRET: Self = Self(23);
163    /// `encrypt_then_mac`.
164    pub const ENCRYPT_THEN_MAC: Self = Self(22);
165    /// `session_ticket`.
166    pub const SESSION_TICKET: Self = Self(35);
167    /// `signature_algorithms`.
168    pub const SIGNATURE_ALGORITHMS: Self = Self(13);
169    /// `supported_versions`.
170    pub const SUPPORTED_VERSIONS: Self = Self(43);
171    /// `psk_key_exchange_modes`.
172    pub const PSK_KEY_EXCHANGE_MODES: Self = Self(45);
173    /// `key_share`.
174    pub const KEY_SHARE: Self = Self(51);
175    /// `supported_groups` (a.k.a. `elliptic_curves`).
176    pub const SUPPORTED_GROUPS: Self = Self(10);
177    pub const EC_POINT_FORMATS: Self = Self(11);
178    pub const ALPN: Self = Self(16);
179    /// `status_request` (OCSP stapling).
180    pub const STATUS_REQUEST: Self = Self(5);
181    /// `signed_certificate_timestamp`.
182    pub const SIGNED_CERTIFICATE_TIMESTAMP: Self = Self(18);
183    /// `compress_certificate`.
184    pub const COMPRESS_CERTIFICATE: Self = Self(27);
185    /// `application_settings` (ALPS).
186    pub const APPLICATION_SETTINGS: Self = Self(17513);
187    /// `renegotiation_info`.
188    pub const RENEGOTIATION_INFO: Self = Self(0xff01);
189    /// `delegated_credentials`.
190    pub const DELEGATED_CREDENTIALS: Self = Self(34);
191    /// `record_size_limit`.
192    pub const RECORD_SIZE_LIMIT: Self = Self(28);
193    /// padding.
194    pub const PADDING: Self = Self(21);
195    /// `pre_shared_key`.
196    pub const PRE_SHARED_KEY: Self = Self(41);
197    /// `post_handshake_auth`.
198    pub const POST_HANDSHAKE_AUTH: Self = Self(49);
199}
200
201impl fmt::Display for TlsExtensionId {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        write!(f, "{}", self.0)
204    }
205}
206
207/// Named group (elliptic curve / key-exchange group) identifier.
208///
209/// # Example
210///
211/// ```
212/// use stygian_browser::tls::SupportedGroup;
213///
214/// let x25519 = SupportedGroup::X25519;
215/// assert_eq!(x25519.iana_value(), 0x001d);
216/// ```
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
218#[non_exhaustive]
219pub enum SupportedGroup {
220    /// X25519 Diffie-Hellman (0x001d).
221    X25519,
222    /// secp256r1 / P-256 (0x0017).
223    SecP256r1,
224    /// secp384r1 / P-384 (0x0018).
225    SecP384r1,
226    /// secp521r1 / P-521 (0x0019).
227    SecP521r1,
228    /// `X25519Kyber768Draft00` — post-quantum hybrid (0x6399).
229    X25519Kyber768,
230    /// FFDHE2048 (0x0100).
231    Ffdhe2048,
232    /// FFDHE3072 (0x0101).
233    Ffdhe3072,
234}
235
236impl SupportedGroup {
237    /// Return the two-byte IANA named-group value.
238    ///
239    /// # Example
240    ///
241    /// ```
242    /// use stygian_browser::tls::SupportedGroup;
243    ///
244    /// assert_eq!(SupportedGroup::SecP256r1.iana_value(), 0x0017);
245    /// ```
246    pub const fn iana_value(self) -> u16 {
247        match self {
248            Self::X25519 => 0x001d,
249            Self::SecP256r1 => 0x0017,
250            Self::SecP384r1 => 0x0018,
251            Self::SecP521r1 => 0x0019,
252            Self::X25519Kyber768 => 0x6399,
253            Self::Ffdhe2048 => 0x0100,
254            Self::Ffdhe3072 => 0x0101,
255        }
256    }
257}
258
259impl fmt::Display for SupportedGroup {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        write!(f, "{}", self.iana_value())
262    }
263}
264
265/// TLS signature algorithm identifier (IANA two-byte code point).
266///
267/// # Example
268///
269/// ```
270/// use stygian_browser::tls::SignatureAlgorithm;
271///
272/// let ecdsa = SignatureAlgorithm::ECDSA_SECP256R1_SHA256;
273/// assert_eq!(ecdsa.0, 0x0403);
274/// ```
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
276pub struct SignatureAlgorithm(pub u16);
277
278impl SignatureAlgorithm {
279    /// `ecdsa_secp256r1_sha256`.
280    pub const ECDSA_SECP256R1_SHA256: Self = Self(0x0403);
281    /// `rsa_pss_rsae_sha256`.
282    pub const RSA_PSS_RSAE_SHA256: Self = Self(0x0804);
283    /// `rsa_pkcs1_sha256`.
284    pub const RSA_PKCS1_SHA256: Self = Self(0x0401);
285    /// `ecdsa_secp384r1_sha384`.
286    pub const ECDSA_SECP384R1_SHA384: Self = Self(0x0503);
287    /// `rsa_pss_rsae_sha384`.
288    pub const RSA_PSS_RSAE_SHA384: Self = Self(0x0805);
289    /// `rsa_pkcs1_sha384`.
290    pub const RSA_PKCS1_SHA384: Self = Self(0x0501);
291    /// `rsa_pss_rsae_sha512`.
292    pub const RSA_PSS_RSAE_SHA512: Self = Self(0x0806);
293    /// `rsa_pkcs1_sha512`.
294    pub const RSA_PKCS1_SHA512: Self = Self(0x0601);
295    /// `ecdsa_secp521r1_sha512`.
296    pub const ECDSA_SECP521R1_SHA512: Self = Self(0x0603);
297    /// `rsa_pkcs1_sha1` (legacy).
298    pub const RSA_PKCS1_SHA1: Self = Self(0x0201);
299    /// `ecdsa_sha1` (legacy).
300    pub const ECDSA_SHA1: Self = Self(0x0203);
301}
302
303impl fmt::Display for SignatureAlgorithm {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        write!(f, "{}", self.0)
306    }
307}
308
309///
310/// # Example
311///
312/// ```rust
313/// use stygian_browser::tls::AlpnProtocol;
314/// assert_eq!(AlpnProtocol::H2.as_str(), "h2");
315/// ```
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
317#[non_exhaustive]
318pub enum AlpnProtocol {
319    /// HTTP/2 (`h2`).
320    H2,
321    /// HTTP/1.1 (`http/1.1`).
322    Http11,
323}
324
325impl AlpnProtocol {
326    /// Returns the wire string for this protocol.
327    ///
328    /// # Example
329    ///
330    /// ```rust
331    /// use stygian_browser::tls::AlpnProtocol;
332    /// assert_eq!(AlpnProtocol::Http11.as_str(), "http/1.1");
333    /// ```
334    pub const fn as_str(self) -> &'static str {
335        match self {
336            Self::H2 => "h2",
337            Self::Http11 => "http/1.1",
338        }
339    }
340}
341
342impl fmt::Display for AlpnProtocol {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        f.write_str(self.as_str())
345    }
346}
347
348// ── TLS profile ──────────────────────────────────────────────────────────────
349
350/// A complete TLS fingerprint profile matching a real browser's `ClientHello`.
351///
352/// The ordering of cipher suites, extensions, and supported groups matters —
353/// anti-bot systems compare these orderings against known browser signatures.
354///
355/// # Example
356///
357/// ```
358/// use stygian_browser::tls::{CHROME_131, TlsProfile};
359///
360/// let profile: &TlsProfile = &*CHROME_131;
361/// assert_eq!(profile.name, "Chrome 131");
362/// assert!(!profile.cipher_suites.is_empty());
363/// ```
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365#[non_exhaustive]
366pub struct TlsProfile {
367    /// Human-readable profile name (e.g. `"Chrome 131"`).
368    pub name: String,
369    /// Ordered cipher-suite list from the `ClientHello`.
370    pub cipher_suites: Vec<CipherSuiteId>,
371    pub tls_versions: Vec<TlsVersion>,
372    /// Ordered extension list from the `ClientHello`.
373    pub extensions: Vec<TlsExtensionId>,
374    /// Supported named groups (elliptic curves / key exchange).
375    pub supported_groups: Vec<SupportedGroup>,
376    /// Supported signature algorithms.
377    pub signature_algorithms: Vec<SignatureAlgorithm>,
378    pub alpn_protocols: Vec<AlpnProtocol>,
379}
380
381// ── JA3 ──────────────────────────────────────────────────────────────────────
382
383///
384///
385/// Fields within each section are dash-separated.
386///
387/// # Example
388///
389/// ```
390/// use stygian_browser::tls::CHROME_131;
391///
392/// let ja3 = CHROME_131.ja3();
393/// assert!(ja3.raw.contains(','));
394/// assert_eq!(ja3.hash.len(), 32); // MD5 hex digest
395/// ```
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397pub struct Ja3Hash {
398    pub raw: String,
399    /// MD5 hex digest of [`raw`](Ja3Hash::raw).
400    pub hash: String,
401}
402
403impl fmt::Display for Ja3Hash {
404    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405        f.write_str(&self.hash)
406    }
407}
408
409/// Compute MD5 of `data` and return a 32-char lowercase hex string.
410#[allow(
411    clippy::many_single_char_names,
412    clippy::too_many_lines,
413    clippy::indexing_slicing
414)]
415fn md5_hex(data: &[u8]) -> String {
416    // Per-round shift amounts.
417    const S: [u32; 64] = [
418        7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5,
419        9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10,
420        15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
421    ];
422
423    // Pre-computed T[i] = floor(2^32 * |sin(i+1)|).
424    const K: [u32; 64] = [
425        0xd76a_a478,
426        0xe8c7_b756,
427        0x2420_70db,
428        0xc1bd_ceee,
429        0xf57c_0faf,
430        0x4787_c62a,
431        0xa830_4613,
432        0xfd46_9501,
433        0x6980_98d8,
434        0x8b44_f7af,
435        0xffff_5bb1,
436        0x895c_d7be,
437        0x6b90_1122,
438        0xfd98_7193,
439        0xa679_438e,
440        0x49b4_0821,
441        0xf61e_2562,
442        0xc040_b340,
443        0x265e_5a51,
444        0xe9b6_c7aa,
445        0xd62f_105d,
446        0x0244_1453,
447        0xd8a1_e681,
448        0xe7d3_fbc8,
449        0x21e1_cde6,
450        0xc337_07d6,
451        0xf4d5_0d87,
452        0x455a_14ed,
453        0xa9e3_e905,
454        0xfcef_a3f8,
455        0x676f_02d9,
456        0x8d2a_4c8a,
457        0xfffa_3942,
458        0x8771_f681,
459        0x6d9d_6122,
460        0xfde5_380c,
461        0xa4be_ea44,
462        0x4bde_cfa9,
463        0xf6bb_4b60,
464        0xbebf_bc70,
465        0x289b_7ec6,
466        0xeaa1_27fa,
467        0xd4ef_3085,
468        0x0488_1d05,
469        0xd9d4_d039,
470        0xe6db_99e5,
471        0x1fa2_7cf8,
472        0xc4ac_5665,
473        0xf429_2244,
474        0x432a_ff97,
475        0xab94_23a7,
476        0xfc93_a039,
477        0x655b_59c3,
478        0x8f0c_cc92,
479        0xffef_f47d,
480        0x8584_5dd1,
481        0x6fa8_7e4f,
482        0xfe2c_e6e0,
483        0xa301_4314,
484        0x4e08_11a1,
485        0xf753_7e82,
486        0xbd3a_f235,
487        0x2ad7_d2bb,
488        0xeb86_d391,
489    ];
490
491    // Pre-processing: add padding.
492    let orig_len_bits = (data.len() as u64).wrapping_mul(8);
493    let mut msg = data.to_vec();
494    msg.push(0x80);
495    while msg.len() % 64 != 56 {
496        msg.push(0);
497    }
498    msg.extend_from_slice(&orig_len_bits.to_le_bytes());
499
500    let mut a0: u32 = 0x6745_2301;
501    let mut b0: u32 = 0xefcd_ab89;
502    let mut c0: u32 = 0x98ba_dcfe;
503    let mut d0: u32 = 0x1032_5476;
504
505    for chunk in msg.chunks_exact(64) {
506        let mut m = [0u32; 16];
507        for (word, quad) in m.iter_mut().zip(chunk.chunks_exact(4)) {
508            // chunks_exact(4) on a 64-byte slice always yields exactly 16
509            if let Ok(bytes) = <[u8; 4]>::try_from(quad) {
510                *word = u32::from_le_bytes(bytes);
511            }
512        }
513
514        let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
515
516        for i in 0..64 {
517            let (f, g) = match i {
518                0..=15 => ((b & c) | ((!b) & d), i),
519                16..=31 => ((d & b) | ((!d) & c), (5 * i + 1) % 16),
520                32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
521                _ => (c ^ (b | (!d)), (7 * i) % 16),
522            };
523            let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]);
524            a = d;
525            d = c;
526            c = b;
527            b = b.wrapping_add(f.rotate_left(S[i]));
528        }
529
530        a0 = a0.wrapping_add(a);
531        b0 = b0.wrapping_add(b);
532        c0 = c0.wrapping_add(c);
533        d0 = d0.wrapping_add(d);
534    }
535
536    let digest = [
537        a0.to_le_bytes(),
538        b0.to_le_bytes(),
539        c0.to_le_bytes(),
540        d0.to_le_bytes(),
541    ];
542    let mut hex = String::with_capacity(32);
543    for group in &digest {
544        for &byte in group {
545            use fmt::Write;
546            let _ = write!(hex, "{byte:02x}");
547        }
548    }
549    hex
550}
551
552// ── JA4 ──────────────────────────────────────────────────────────────────────
553
554///
555///
556/// # Example
557///
558/// ```
559/// use stygian_browser::tls::CHROME_131;
560///
561/// let ja4 = CHROME_131.ja4();
562/// assert!(ja4.fingerprint.starts_with("t13"));
563/// ```
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
565pub struct Ja4 {
566    /// The full JA4 fingerprint string.
567    pub fingerprint: String,
568}
569
570impl fmt::Display for Ja4 {
571    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
572        f.write_str(&self.fingerprint)
573    }
574}
575
576// ── HTTP/3 Perk ─────────────────────────────────────────────────────────────
577
578/// `SETTINGS|PSEUDO_HEADERS`.
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
580pub struct Http3Perk {
581    /// Ordered HTTP/3 settings as `(id, value)` tuples.
582    pub settings: Vec<(u64, u64)>,
583    pub pseudo_headers: String,
584    pub has_grease: bool,
585}
586
587/// Result of comparing expected and observed HTTP/3 perk data.
588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
589pub struct Http3PerkComparison {
590    /// `true` only when all available observed fields match expected values.
591    pub matches: bool,
592    /// Human-readable mismatch reasons.
593    pub mismatches: Vec<String>,
594}
595
596const fn is_quic_grease(value: u64) -> bool {
597    let low = value & 0xffff;
598    let a = (low >> 8) & 0xff;
599    let b = low & 0xff;
600    a == b && (a & 0x0f) == 0x0a
601}
602
603impl Http3Perk {
604    /// Return canonical `perk_text` as `SETTINGS|PSEUDO_HEADERS`.
605    #[must_use]
606    pub fn perk_text(&self) -> String {
607        let mut parts: Vec<String> = self
608            .settings
609            .iter()
610            .filter(|(id, _)| !is_quic_grease(*id))
611            .map(|(id, value)| format!("{id}:{value}"))
612            .collect();
613
614        if self.has_grease || self.settings.iter().any(|(id, _)| is_quic_grease(*id)) {
615            parts.push("GREASE".to_string());
616        }
617
618        format!("{}|{}", parts.join(";"), self.pseudo_headers)
619    }
620
621    /// Return MD5 hash of [`perk_text`](Self::perk_text), lowercase hex.
622    #[must_use]
623    pub fn perk_hash(&self) -> String {
624        md5_hex(self.perk_text().as_bytes())
625    }
626
627    /// Compare observed perk text/hash against this expected fingerprint.
628    #[must_use]
629    pub fn compare(
630        &self,
631        observed_text: Option<&str>,
632        observed_hash: Option<&str>,
633    ) -> Http3PerkComparison {
634        let expected_text = self.perk_text();
635        let expected_hash = self.perk_hash();
636
637        let mut mismatches = Vec::new();
638
639        if let Some(text) = observed_text
640            && text != expected_text
641        {
642            mismatches.push(format!(
643                "perk_text mismatch: expected '{expected_text}', observed '{text}'"
644            ));
645        }
646
647        if let Some(hash) = observed_hash
648            && !hash.eq_ignore_ascii_case(&expected_hash)
649        {
650            mismatches.push(format!(
651                "perk_hash mismatch: expected '{expected_hash}', observed '{hash}'"
652            ));
653        }
654
655        Http3PerkComparison {
656            matches: mismatches.is_empty() && (observed_text.is_some() || observed_hash.is_some()),
657            mismatches,
658        }
659    }
660}
661
662/// Build an expected HTTP/3 perk fingerprint from a User-Agent string.
663///
664#[must_use]
665pub fn expected_http3_perk_from_user_agent(user_agent: &str) -> Option<Http3Perk> {
666    expected_tls_profile_from_user_agent(user_agent).and_then(TlsProfile::http3_perk)
667}
668
669/// Returns the `TlsProfile` for the given user-agent string, if known.
670#[must_use]
671pub fn expected_tls_profile_from_user_agent(user_agent: &str) -> Option<&'static TlsProfile> {
672    let ua = user_agent.to_ascii_lowercase();
673
674    if ua.contains("edg/") {
675        return Some(&EDGE_131);
676    }
677
678    if ua.contains("firefox/") {
679        return Some(&FIREFOX_133);
680    }
681
682    if ua.contains("safari/") && !ua.contains("chrome/") && !ua.contains("edg/") {
683        return Some(&SAFARI_18);
684    }
685
686    if ua.contains("chrome/") {
687        return Some(&CHROME_131);
688    }
689
690    None
691}
692
693#[must_use]
694pub fn expected_ja3_from_user_agent(user_agent: &str) -> Option<Ja3Hash> {
695    expected_tls_profile_from_user_agent(user_agent).map(TlsProfile::ja3)
696}
697
698#[must_use]
699pub fn expected_ja4_from_user_agent(user_agent: &str) -> Option<Ja4> {
700    expected_tls_profile_from_user_agent(user_agent).map(TlsProfile::ja4)
701}
702
703// ── profile methods ──────────────────────────────────────────────────────────
704
705/// Truncates a hex string `s` to at most `n` characters.
706fn truncate_hex(s: &str, n: usize) -> &str {
707    let end = s.len().min(n);
708    &s[..end]
709}
710
711/// GREASE values that must be ignored during JA3/JA4 computation.
712const GREASE_VALUES: &[u16] = &[
713    0x0a0a, 0x1a1a, 0x2a2a, 0x3a3a, 0x4a4a, 0x5a5a, 0x6a6a, 0x7a7a, 0x8a8a, 0x9a9a, 0xaaaa, 0xbaba,
714    0xcaca, 0xdada, 0xeaea, 0xfafa,
715];
716
717/// Return `true` if `v` is a TLS GREASE value.
718fn is_grease(v: u16) -> bool {
719    GREASE_VALUES.contains(&v)
720}
721
722impl TlsProfile {
723    /// Computes the JA3 fingerprint string for this profile.
724    ///
725    /// - GREASE values are stripped from all fields.
726    /// - Fields are ordered as specified in the profile.
727    ///
728    /// # Example
729    ///
730    /// ```
731    /// use stygian_browser::tls::CHROME_131;
732    ///
733    /// let ja3 = CHROME_131.ja3();
734    /// assert!(ja3.raw.starts_with("772,"));
735    /// assert_eq!(ja3.hash.len(), 32);
736    /// ```
737    pub fn ja3(&self) -> Ja3Hash {
738        // TLS version — use highest advertised.
739        let tls_ver = self
740            .tls_versions
741            .iter()
742            .map(|v| v.iana_value())
743            .max()
744            .unwrap_or(TlsVersion::Tls12.iana_value());
745
746        // Ciphers (GREASE stripped).
747        let ciphers: Vec<String> = self
748            .cipher_suites
749            .iter()
750            .filter(|c| !is_grease(c.0))
751            .map(|c| c.0.to_string())
752            .collect();
753
754        // Extensions (GREASE stripped).
755        let extensions: Vec<String> = self
756            .extensions
757            .iter()
758            .filter(|e| !is_grease(e.0))
759            .map(|e| e.0.to_string())
760            .collect();
761
762        // Elliptic curves (GREASE stripped).
763        let curves: Vec<String> = self
764            .supported_groups
765            .iter()
766            .filter(|g| !is_grease(g.iana_value()))
767            .map(|g| g.iana_value().to_string())
768            .collect();
769
770        let ec_point_formats = "0";
771
772        let raw = format!(
773            "{tls_ver},{},{},{},{ec_point_formats}",
774            ciphers.join("-"),
775            extensions.join("-"),
776            curves.join("-"),
777        );
778
779        let hash = md5_hex(raw.as_bytes());
780        Ja3Hash { raw, hash }
781    }
782
783    ///
784    /// `{q}{version}{sni}{cipher_count:02}{ext_count:02}_{alpn}_{sorted_cipher_hash}_{sorted_ext_hash}`
785    ///
786    /// This implements the `JA4_a` (raw fingerprint) portion. Sorted cipher and
787    /// extension hashes use the first 12 hex characters of the SHA-256 —
788    /// approximated here by truncated MD5 since we already have that
789    /// implementation and the goal is fingerprint *representation*, not
790    /// cryptographic security.
791    ///
792    /// # Example
793    ///
794    /// ```
795    /// use stygian_browser::tls::CHROME_131;
796    ///
797    /// let ja4 = CHROME_131.ja4();
798    /// assert!(ja4.fingerprint.starts_with("t13"));
799    /// ```
800    pub fn ja4(&self) -> Ja4 {
801        let proto = 't';
802
803        let version = if self.tls_versions.contains(&TlsVersion::Tls13) {
804            "13"
805        } else {
806            "12"
807        };
808
809        // SNI: 'd' = domain (SNI present), 'i' = IP (no SNI). We assume SNI
810        let sni = 'd';
811
812        // Counts (GREASE stripped), capped at 99.
813        let cipher_count = self
814            .cipher_suites
815            .iter()
816            .filter(|c| !is_grease(c.0))
817            .count()
818            .min(99);
819        let ext_count = self
820            .extensions
821            .iter()
822            .filter(|e| !is_grease(e.0))
823            .count()
824            .min(99);
825
826        // uses first+last chars). '00' when empty.
827        let alpn_tag = match self.alpn_protocols.first() {
828            Some(AlpnProtocol::H2) => "h2",
829            Some(AlpnProtocol::Http11) => "h1",
830            None => "00",
831        };
832
833        let section_a = format!("{proto}{version}{sni}{cipher_count:02}{ext_count:02}_{alpn_tag}",);
834
835        // Section b: sorted cipher suites (GREASE stripped), comma-separated,
836        // hashed, first 12 hex chars.
837        let mut sorted_ciphers: Vec<u16> = self
838            .cipher_suites
839            .iter()
840            .filter(|c| !is_grease(c.0))
841            .map(|c| c.0)
842            .collect();
843        sorted_ciphers.sort_unstable();
844        let cipher_str: String = sorted_ciphers
845            .iter()
846            .map(|c| format!("{c:04x}"))
847            .collect::<Vec<_>>()
848            .join(",");
849        let cipher_hash_full = md5_hex(cipher_str.as_bytes());
850        let cipher_hash = truncate_hex(&cipher_hash_full, 12);
851
852        // Section c: sorted extensions (GREASE + SNI + ALPN stripped),
853        // comma-separated, hashed, first 12 hex chars.
854        let mut sorted_exts: Vec<u16> = self
855            .extensions
856            .iter()
857            .filter(|e| {
858                !is_grease(e.0)
859                    && e.0 != TlsExtensionId::SERVER_NAME.0
860                    && e.0 != TlsExtensionId::ALPN.0
861            })
862            .map(|e| e.0)
863            .collect();
864        sorted_exts.sort_unstable();
865        let ext_str: String = sorted_exts
866            .iter()
867            .map(|e| format!("{e:04x}"))
868            .collect::<Vec<_>>()
869            .join(",");
870        let ext_hash_full = md5_hex(ext_str.as_bytes());
871        let ext_hash = truncate_hex(&ext_hash_full, 12);
872
873        Ja4 {
874            fingerprint: format!("{section_a}_{cipher_hash}_{ext_hash}"),
875        }
876    }
877
878    /// Returns HTTP/3 QUIC settings for browsers that support it, if applicable.
879    #[must_use]
880    pub fn http3_perk(&self) -> Option<Http3Perk> {
881        match self.name.as_str() {
882            name if name.starts_with("Chrome ") || name.starts_with("Edge ") => Some(Http3Perk {
883                settings: vec![(1, 65_536), (6, 262_144), (7, 100), (51, 1)],
884                pseudo_headers: "masp".to_string(),
885                has_grease: true,
886            }),
887            name if name.starts_with("Firefox ") => Some(Http3Perk {
888                settings: vec![(1, 65_536), (7, 20), (727_725_890, 0)],
889                pseudo_headers: "mpas".to_string(),
890                has_grease: false,
891            }),
892            name if name.starts_with("Safari ") => None,
893            _ => None,
894        }
895    }
896
897    /// Select a built-in TLS profile weighted by real browser market share.
898    ///
899    /// Distribution mirrors [`DeviceProfile`](super::fingerprint::DeviceProfile)
900    /// and [`BrowserKind`](super::fingerprint::BrowserKind) weights:
901    ///
902    /// - Windows (70%): Chrome 65%, Edge 16%, Firefox 19%
903    /// - macOS (20%): Chrome 56%, Safari 36%, Firefox 8%
904    /// - Linux (10%): Chrome 65%, Edge 16%, Firefox 19%
905    ///
906    /// Edge 131 shares Chrome's Blink engine so its TLS stack is nearly
907    /// identical; the profile uses [`EDGE_131`].
908    ///
909    /// # Example
910    ///
911    /// ```
912    /// use stygian_browser::tls::TlsProfile;
913    ///
914    /// let profile = TlsProfile::random_weighted(42);
915    /// assert!(!profile.name.is_empty());
916    /// ```
917    pub fn random_weighted(seed: u64) -> &'static Self {
918        // Step 1: pick OS (Windows 70%, Mac 20%, Linux 10%).
919        let os_roll = rng(seed, 97) % 100;
920
921        // Step 2: pick browser within that OS.
922        let browser_roll = rng(seed, 201) % 100;
923
924        match os_roll {
925            // Windows / Linux: Chrome 65%, Edge 16%, Firefox 19%.
926            0..=69 | 90..=99 => match browser_roll {
927                0..=64 => &CHROME_131,
928                65..=80 => &EDGE_131,
929                _ => &FIREFOX_133,
930            },
931            // macOS: Chrome 56%, Safari 36%, Firefox 8%.
932            _ => match browser_roll {
933                0..=55 => &CHROME_131,
934                56..=91 => &SAFARI_18,
935                _ => &FIREFOX_133,
936            },
937        }
938    }
939}
940
941// ── built-in profiles ────────────────────────────────────────────────────────
942
943/// Google Chrome 131 TLS fingerprint profile.
944///
945/// Cipher suites, extensions, and groups sourced from real Chrome 131
946/// `ClientHello` captures.
947///
948/// # Example
949///
950/// ```
951/// use stygian_browser::tls::CHROME_131;
952///
953/// assert_eq!(CHROME_131.name, "Chrome 131");
954/// assert!(CHROME_131.tls_versions.contains(&stygian_browser::tls::TlsVersion::Tls13));
955/// ```
956pub static CHROME_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
957    name: "Chrome 131".to_string(),
958    cipher_suites: vec![
959        CipherSuiteId::TLS_AES_128_GCM_SHA256,
960        CipherSuiteId::TLS_AES_256_GCM_SHA384,
961        CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
962        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
963        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
964        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
965        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
966        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
967        CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
968        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
969        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
970        CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
971        CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
972        CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
973        CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
974    ],
975    tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
976    extensions: vec![
977        TlsExtensionId::SERVER_NAME,
978        TlsExtensionId::EXTENDED_MASTER_SECRET,
979        TlsExtensionId::RENEGOTIATION_INFO,
980        TlsExtensionId::SUPPORTED_GROUPS,
981        TlsExtensionId::EC_POINT_FORMATS,
982        TlsExtensionId::SESSION_TICKET,
983        TlsExtensionId::ALPN,
984        TlsExtensionId::STATUS_REQUEST,
985        TlsExtensionId::SIGNATURE_ALGORITHMS,
986        TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
987        TlsExtensionId::KEY_SHARE,
988        TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
989        TlsExtensionId::SUPPORTED_VERSIONS,
990        TlsExtensionId::COMPRESS_CERTIFICATE,
991        TlsExtensionId::APPLICATION_SETTINGS,
992        TlsExtensionId::PADDING,
993    ],
994    supported_groups: vec![
995        SupportedGroup::X25519Kyber768,
996        SupportedGroup::X25519,
997        SupportedGroup::SecP256r1,
998        SupportedGroup::SecP384r1,
999    ],
1000    signature_algorithms: vec![
1001        SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1002        SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1003        SignatureAlgorithm::RSA_PKCS1_SHA256,
1004        SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1005        SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1006        SignatureAlgorithm::RSA_PKCS1_SHA384,
1007        SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1008        SignatureAlgorithm::RSA_PKCS1_SHA512,
1009    ],
1010    alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1011});
1012
1013/// Mozilla Firefox 133 TLS fingerprint profile.
1014///
1015/// Firefox uses a different cipher-suite and extension order than Chromium
1016/// browsers, preferring `ChaCha20` and including `delegated_credentials`
1017/// and `record_size_limit`.
1018///
1019/// # Example
1020///
1021/// ```
1022/// use stygian_browser::tls::FIREFOX_133;
1023///
1024/// assert_eq!(FIREFOX_133.name, "Firefox 133");
1025/// ```
1026pub static FIREFOX_133: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1027    name: "Firefox 133".to_string(),
1028    cipher_suites: vec![
1029        CipherSuiteId::TLS_AES_128_GCM_SHA256,
1030        CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1031        CipherSuiteId::TLS_AES_256_GCM_SHA384,
1032        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1033        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1034        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1035        CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1036        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1037        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1038        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1039        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1040        CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1041        CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1042        CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1043        CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1044    ],
1045    tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1046    extensions: vec![
1047        TlsExtensionId::SERVER_NAME,
1048        TlsExtensionId::EXTENDED_MASTER_SECRET,
1049        TlsExtensionId::RENEGOTIATION_INFO,
1050        TlsExtensionId::SUPPORTED_GROUPS,
1051        TlsExtensionId::EC_POINT_FORMATS,
1052        TlsExtensionId::SESSION_TICKET,
1053        TlsExtensionId::ALPN,
1054        TlsExtensionId::STATUS_REQUEST,
1055        TlsExtensionId::DELEGATED_CREDENTIALS,
1056        TlsExtensionId::KEY_SHARE,
1057        TlsExtensionId::SUPPORTED_VERSIONS,
1058        TlsExtensionId::SIGNATURE_ALGORITHMS,
1059        TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1060        TlsExtensionId::RECORD_SIZE_LIMIT,
1061        TlsExtensionId::POST_HANDSHAKE_AUTH,
1062        TlsExtensionId::PADDING,
1063    ],
1064    supported_groups: vec![
1065        SupportedGroup::X25519,
1066        SupportedGroup::SecP256r1,
1067        SupportedGroup::SecP384r1,
1068        SupportedGroup::SecP521r1,
1069        SupportedGroup::Ffdhe2048,
1070        SupportedGroup::Ffdhe3072,
1071    ],
1072    signature_algorithms: vec![
1073        SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1074        SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1075        SignatureAlgorithm::ECDSA_SECP521R1_SHA512,
1076        SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1077        SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1078        SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1079        SignatureAlgorithm::RSA_PKCS1_SHA256,
1080        SignatureAlgorithm::RSA_PKCS1_SHA384,
1081        SignatureAlgorithm::RSA_PKCS1_SHA512,
1082        SignatureAlgorithm::ECDSA_SHA1,
1083        SignatureAlgorithm::RSA_PKCS1_SHA1,
1084    ],
1085    alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1086});
1087
1088/// Apple Safari 18 TLS fingerprint profile.
1089///
1090/// Safari's TLS stack differs from Chromium in extension order and supported
1091/// groups. Safari does not advertise post-quantum key exchange.
1092///
1093/// # Example
1094///
1095/// ```
1096/// use stygian_browser::tls::SAFARI_18;
1097///
1098/// assert_eq!(SAFARI_18.name, "Safari 18");
1099/// ```
1100pub static SAFARI_18: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1101    name: "Safari 18".to_string(),
1102    cipher_suites: vec![
1103        CipherSuiteId::TLS_AES_128_GCM_SHA256,
1104        CipherSuiteId::TLS_AES_256_GCM_SHA384,
1105        CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1106        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1107        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1108        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1109        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1110        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1111        CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1112        CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1113        CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1114        CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1115        CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1116    ],
1117    tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1118    extensions: vec![
1119        TlsExtensionId::SERVER_NAME,
1120        TlsExtensionId::EXTENDED_MASTER_SECRET,
1121        TlsExtensionId::RENEGOTIATION_INFO,
1122        TlsExtensionId::SUPPORTED_GROUPS,
1123        TlsExtensionId::EC_POINT_FORMATS,
1124        TlsExtensionId::ALPN,
1125        TlsExtensionId::STATUS_REQUEST,
1126        TlsExtensionId::SIGNATURE_ALGORITHMS,
1127        TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1128        TlsExtensionId::KEY_SHARE,
1129        TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1130        TlsExtensionId::SUPPORTED_VERSIONS,
1131        TlsExtensionId::PADDING,
1132    ],
1133    supported_groups: vec![
1134        SupportedGroup::X25519,
1135        SupportedGroup::SecP256r1,
1136        SupportedGroup::SecP384r1,
1137        SupportedGroup::SecP521r1,
1138    ],
1139    signature_algorithms: vec![
1140        SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1141        SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1142        SignatureAlgorithm::RSA_PKCS1_SHA256,
1143        SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1144        SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1145        SignatureAlgorithm::RSA_PKCS1_SHA384,
1146        SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1147        SignatureAlgorithm::RSA_PKCS1_SHA512,
1148    ],
1149    alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1150});
1151
1152/// Microsoft Edge 131 TLS fingerprint profile.
1153///
1154/// Differences are minor (e.g. extension ordering around `application_settings`).
1155///
1156/// # Example
1157///
1158/// ```
1159/// use stygian_browser::tls::EDGE_131;
1160///
1161/// assert_eq!(EDGE_131.name, "Edge 131");
1162/// ```
1163pub static EDGE_131: LazyLock<TlsProfile> = LazyLock::new(|| TlsProfile {
1164    name: "Edge 131".to_string(),
1165    cipher_suites: vec![
1166        CipherSuiteId::TLS_AES_128_GCM_SHA256,
1167        CipherSuiteId::TLS_AES_256_GCM_SHA384,
1168        CipherSuiteId::TLS_CHACHA20_POLY1305_SHA256,
1169        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
1170        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
1171        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
1172        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
1173        CipherSuiteId::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
1174        CipherSuiteId::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
1175        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
1176        CipherSuiteId::TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
1177        CipherSuiteId::TLS_RSA_WITH_AES_128_GCM_SHA256,
1178        CipherSuiteId::TLS_RSA_WITH_AES_256_GCM_SHA384,
1179        CipherSuiteId::TLS_RSA_WITH_AES_128_CBC_SHA,
1180        CipherSuiteId::TLS_RSA_WITH_AES_256_CBC_SHA,
1181    ],
1182    tls_versions: vec![TlsVersion::Tls12, TlsVersion::Tls13],
1183    extensions: vec![
1184        TlsExtensionId::SERVER_NAME,
1185        TlsExtensionId::EXTENDED_MASTER_SECRET,
1186        TlsExtensionId::RENEGOTIATION_INFO,
1187        TlsExtensionId::SUPPORTED_GROUPS,
1188        TlsExtensionId::EC_POINT_FORMATS,
1189        TlsExtensionId::SESSION_TICKET,
1190        TlsExtensionId::ALPN,
1191        TlsExtensionId::STATUS_REQUEST,
1192        TlsExtensionId::SIGNATURE_ALGORITHMS,
1193        TlsExtensionId::SIGNED_CERTIFICATE_TIMESTAMP,
1194        TlsExtensionId::KEY_SHARE,
1195        TlsExtensionId::PSK_KEY_EXCHANGE_MODES,
1196        TlsExtensionId::SUPPORTED_VERSIONS,
1197        TlsExtensionId::COMPRESS_CERTIFICATE,
1198        TlsExtensionId::PADDING,
1199    ],
1200    supported_groups: vec![
1201        SupportedGroup::X25519Kyber768,
1202        SupportedGroup::X25519,
1203        SupportedGroup::SecP256r1,
1204        SupportedGroup::SecP384r1,
1205    ],
1206    signature_algorithms: vec![
1207        SignatureAlgorithm::ECDSA_SECP256R1_SHA256,
1208        SignatureAlgorithm::RSA_PSS_RSAE_SHA256,
1209        SignatureAlgorithm::RSA_PKCS1_SHA256,
1210        SignatureAlgorithm::ECDSA_SECP384R1_SHA384,
1211        SignatureAlgorithm::RSA_PSS_RSAE_SHA384,
1212        SignatureAlgorithm::RSA_PKCS1_SHA384,
1213        SignatureAlgorithm::RSA_PSS_RSAE_SHA512,
1214        SignatureAlgorithm::RSA_PKCS1_SHA512,
1215    ],
1216    alpn_protocols: vec![AlpnProtocol::H2, AlpnProtocol::Http11],
1217});
1218
1219// ── Chrome launch flags ──────────────────────────────────────────────────────
1220
1221///
1222/// # What flags control
1223///
1224/// | Flag | Effect |
1225/// |---|---|
1226/// | `--ssl-version-max` | Cap the highest advertised TLS version |
1227/// | `--ssl-version-min` | Raise the lowest advertised TLS version |
1228///
1229/// # What flags **cannot** control
1230///
1231/// Chrome's TLS stack (`BoringSSL`) hard-codes the following in its compiled binary:
1232///
1233/// - **Cipher-suite ordering** — set by `ssl_cipher_apply_rule` at build time.
1234/// - **Extension ordering** — emitted in a fixed order by `BoringSSL`.
1235/// - **Supported-group ordering** — set at build time.
1236///
1237///
1238///
1239/// | Detection layer | Handled by |
1240/// |---|---|
1241/// | JavaScript leaks | CDP stealth scripts (see [`stealth`](super::stealth)) |
1242/// | CDP signals | [`CdpFixMode`](super::cdp_protection::CdpFixMode) |
1243/// | TLS fingerprint | **Flags (this fn)** — version only; full control needs rustls or patched Chrome |
1244pub fn chrome_tls_args(profile: &TlsProfile) -> Vec<String> {
1245    let has_12 = profile.tls_versions.contains(&TlsVersion::Tls12);
1246    let has_13 = profile.tls_versions.contains(&TlsVersion::Tls13);
1247
1248    let mut args = Vec::new();
1249
1250    match (has_12, has_13) {
1251        (true, false) => {
1252            args.push("--ssl-version-max=tls1.2".to_string());
1253        }
1254        // TLS 1.3 only — raise floor so Chrome skips 1.2.
1255        (false, true) => {
1256            args.push("--ssl-version-min=tls1.3".to_string());
1257        }
1258        // Both supported or empty — Chrome's defaults are fine.
1259        _ => {}
1260    }
1261
1262    args
1263}
1264
1265// ── rustls integration ───────────────────────────────────────────────────────
1266//
1267// Feature-gated behind `tls-config`. Builds a rustls `ClientConfig` from a
1268// the profile's cipher-suite, key-exchange-group, ALPN, and version ordering.
1269
1270#[cfg(feature = "tls-config")]
1271mod rustls_config {
1272    #[allow(clippy::wildcard_imports)]
1273    use super::*;
1274    use std::sync::Arc;
1275
1276    ///
1277    /// This struct lets callers choose between broad compatibility and strict
1278    ///
1279    /// - **Compatible mode** (default) skips unsupported profile entries with
1280    ///   and unsupported groups.
1281    ///
1282    /// # Example
1283    ///
1284    /// ```
1285    /// use stygian_browser::tls::TlsControl;
1286    ///
1287    /// let strict = TlsControl::strict();
1288    /// assert!(strict.strict_cipher_suites);
1289    /// ```
1290    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1291    pub struct TlsControl {
1292        /// Fail if any profile cipher suite is unsupported by rustls.
1293        pub strict_cipher_suites: bool,
1294        /// Fail if any profile supported-group entry is unsupported by rustls.
1295        pub strict_supported_groups: bool,
1296        /// If no profile groups can be mapped, use provider default groups.
1297        pub fallback_to_provider_groups: bool,
1298        /// Skip legacy JA3-only suites that rustls cannot implement.
1299        pub allow_legacy_compat_suites: bool,
1300    }
1301
1302    impl Default for TlsControl {
1303        fn default() -> Self {
1304            Self::compatible()
1305        }
1306    }
1307
1308    impl TlsControl {
1309        #[must_use]
1310        pub const fn compatible() -> Self {
1311            Self {
1312                strict_cipher_suites: false,
1313                strict_supported_groups: false,
1314                fallback_to_provider_groups: true,
1315                allow_legacy_compat_suites: true,
1316            }
1317        }
1318
1319        /// Strict mode: reject unknown cipher suites.
1320        #[must_use]
1321        pub const fn strict() -> Self {
1322            Self {
1323                strict_cipher_suites: true,
1324                strict_supported_groups: false,
1325                fallback_to_provider_groups: true,
1326                allow_legacy_compat_suites: true,
1327            }
1328        }
1329
1330        /// Strict-all mode: reject unknown entries and avoid fallback groups.
1331        #[must_use]
1332        pub const fn strict_all() -> Self {
1333            Self {
1334                strict_cipher_suites: true,
1335                strict_supported_groups: true,
1336                fallback_to_provider_groups: false,
1337                allow_legacy_compat_suites: true,
1338            }
1339        }
1340
1341        ///
1342        /// Browser profiles use strict cipher-suite checking while allowing
1343        #[must_use]
1344        pub fn for_profile(profile: &TlsProfile) -> Self {
1345            let name = profile.name.to_ascii_lowercase();
1346            if name.contains("chrome")
1347                || name.contains("edge")
1348                || name.contains("firefox")
1349                || name.contains("safari")
1350            {
1351                Self::strict()
1352            } else {
1353                Self::compatible()
1354            }
1355        }
1356    }
1357
1358    const fn is_legacy_compat_suite(id: u16) -> bool {
1359        matches!(id, 0xc013 | 0xc014 | 0x009c | 0x009d | 0x002f | 0x0035)
1360    }
1361
1362    /// Error building a rustls [`ClientConfig`](rustls::ClientConfig) from a
1363    /// [`TlsProfile`].
1364    #[derive(Debug, thiserror::Error)]
1365    #[non_exhaustive]
1366    pub enum TlsConfigError {
1367        /// None of the profile's cipher suites are supported by the rustls
1368        #[error("no supported cipher suites in profile '{0}'")]
1369        NoCipherSuites(String),
1370
1371        /// Strict mode rejected an unsupported cipher suite.
1372        #[error(
1373            "unsupported cipher suite {cipher_suite_id:#06x} in profile '{profile}' under strict mode"
1374        )]
1375        UnsupportedCipherSuite {
1376            /// Profile name used in the attempted mapping.
1377            profile: String,
1378            /// Unsupported IANA cipher suite code point.
1379            cipher_suite_id: u16,
1380        },
1381
1382        /// Strict mode rejected an unsupported key-exchange group.
1383        #[error(
1384            "unsupported supported_group {group_id:#06x} in profile '{profile}' under strict mode"
1385        )]
1386        UnsupportedSupportedGroup {
1387            /// Profile name used in the attempted mapping.
1388            profile: String,
1389            /// Unsupported IANA supported-group code point.
1390            group_id: u16,
1391        },
1392
1393        /// No supported groups are available and fallback is disabled.
1394        #[error("no supported key-exchange groups in profile '{0}'")]
1395        NoSupportedGroups(String),
1396
1397        #[error("rustls configuration: {0}")]
1398        Rustls(#[from] rustls::Error),
1399    }
1400
1401    /// Wrapper around `Arc<rustls::ClientConfig>` built from a [`TlsProfile`].
1402    ///
1403    /// `reqwest::ClientBuilder::use_preconfigured_tls` (T14) or use it
1404    #[derive(Debug, Clone)]
1405    pub struct TlsClientConfig(Arc<rustls::ClientConfig>);
1406
1407    impl TlsClientConfig {
1408        /// Borrow the inner `ClientConfig`.
1409        pub fn inner(&self) -> &rustls::ClientConfig {
1410            &self.0
1411        }
1412
1413        pub fn into_inner(self) -> Arc<rustls::ClientConfig> {
1414            self.0
1415        }
1416    }
1417
1418    impl From<TlsClientConfig> for Arc<rustls::ClientConfig> {
1419        fn from(cfg: TlsClientConfig) -> Self {
1420            cfg.0
1421        }
1422    }
1423
1424    impl TlsProfile {
1425        /// Build a rustls `ClientConfig` matching this profile.
1426        ///
1427        ///
1428        /// # Errors
1429        ///
1430        /// profile's cipher suites are available in the backend.
1431        ///
1432        /// # rustls extension control
1433        ///
1434        ///
1435        /// - `supported_versions`, `key_share`, `signature_algorithms`,
1436        ///   `supported_groups`, `server_name`, `psk_key_exchange_modes`, and
1437        ///
1438        /// Extensions like `compress_certificate`, `application_settings`,
1439        /// `delegated_credentials`, and `signed_certificate_timestamp` are
1440        /// not configurable in rustls and are emitted (or not) based on the
1441        /// library version.
1442        pub fn to_rustls_config(&self) -> Result<TlsClientConfig, TlsConfigError> {
1443            self.to_rustls_config_with_control(TlsControl::default())
1444        }
1445
1446        /// Build a rustls `ClientConfig` using explicit control settings.
1447        ///
1448        /// introducing native TLS dependencies.
1449        ///
1450        /// # Limitations
1451        ///
1452        /// ordering or GREASE emission. This method provides strict control
1453        /// over the fields rustls does expose (cipher suites, groups, ALPN,
1454        ///
1455        /// # Example
1456        ///
1457        /// ```
1458        /// use stygian_browser::tls::{CHROME_131, TlsControl};
1459        ///
1460        /// let result = CHROME_131.to_rustls_config_with_control(TlsControl::default());
1461        /// assert!(result.is_ok());
1462        /// ```
1463        pub fn to_rustls_config_with_control(
1464            &self,
1465            control: TlsControl,
1466        ) -> Result<TlsClientConfig, TlsConfigError> {
1467            let default = rustls::crypto::aws_lc_rs::default_provider();
1468
1469            // ── cipher suites ──
1470            let suite_map: std::collections::HashMap<u16, rustls::SupportedCipherSuite> = default
1471                .cipher_suites
1472                .iter()
1473                .map(|cs| (u16::from(cs.suite()), *cs))
1474                .collect();
1475
1476            let mut ordered_suites: Vec<rustls::SupportedCipherSuite> = Vec::new();
1477            for id in &self.cipher_suites {
1478                if let Some(cs) = suite_map.get(&id.0).copied() {
1479                    ordered_suites.push(cs);
1480                } else if control.allow_legacy_compat_suites && is_legacy_compat_suite(id.0) {
1481                    tracing::warn!(
1482                        cipher_suite_id = id.0,
1483                        profile = %self.name,
1484                        "legacy profile suite has no rustls equivalent, skipping"
1485                    );
1486                } else if control.strict_cipher_suites {
1487                    return Err(TlsConfigError::UnsupportedCipherSuite {
1488                        profile: self.name.clone(),
1489                        cipher_suite_id: id.0,
1490                    });
1491                } else {
1492                    tracing::warn!(
1493                        cipher_suite_id = id.0,
1494                        profile = %self.name,
1495                        "cipher suite not supported by rustls aws-lc-rs backend, skipping"
1496                    );
1497                }
1498            }
1499
1500            if ordered_suites.is_empty() {
1501                return Err(TlsConfigError::NoCipherSuites(self.name.clone()));
1502            }
1503
1504            // ── key-exchange groups ──
1505            let group_map: std::collections::HashMap<
1506                u16,
1507                &'static dyn rustls::crypto::SupportedKxGroup,
1508            > = default
1509                .kx_groups
1510                .iter()
1511                .map(|g| (u16::from(g.name()), *g))
1512                .collect();
1513
1514            let mut ordered_groups: Vec<&'static dyn rustls::crypto::SupportedKxGroup> = Vec::new();
1515            for sg in &self.supported_groups {
1516                if let Some(group) = group_map.get(&sg.iana_value()).copied() {
1517                    ordered_groups.push(group);
1518                } else if control.strict_supported_groups {
1519                    return Err(TlsConfigError::UnsupportedSupportedGroup {
1520                        profile: self.name.clone(),
1521                        group_id: sg.iana_value(),
1522                    });
1523                } else {
1524                    tracing::warn!(
1525                        group_id = sg.iana_value(),
1526                        profile = %self.name,
1527                        "key-exchange group not supported by rustls, skipping"
1528                    );
1529                }
1530            }
1531
1532            let kx_groups = if ordered_groups.is_empty() && control.fallback_to_provider_groups {
1533                default.kx_groups.clone()
1534            } else if ordered_groups.is_empty() {
1535                return Err(TlsConfigError::NoSupportedGroups(self.name.clone()));
1536            } else {
1537                ordered_groups
1538            };
1539
1540            let provider = rustls::crypto::CryptoProvider {
1541                cipher_suites: ordered_suites,
1542                kx_groups,
1543                ..default
1544            };
1545
1546            // ── TLS versions ──
1547            let versions: Vec<&'static rustls::SupportedProtocolVersion> = self
1548                .tls_versions
1549                .iter()
1550                .map(|v| match v {
1551                    TlsVersion::Tls12 => &rustls::version::TLS12,
1552                    TlsVersion::Tls13 => &rustls::version::TLS13,
1553                })
1554                .collect();
1555
1556            let mut root_store = rustls::RootCertStore::empty();
1557            root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
1558
1559            // ── build ClientConfig ──
1560            let mut config = rustls::ClientConfig::builder_with_provider(Arc::new(provider))
1561                .with_protocol_versions(&versions)?
1562                .with_root_certificates(root_store)
1563                .with_no_client_auth();
1564
1565            // ── ALPN ──
1566            config.alpn_protocols = self
1567                .alpn_protocols
1568                .iter()
1569                .map(|p| p.as_str().as_bytes().to_vec())
1570                .collect();
1571
1572            Ok(TlsClientConfig(Arc::new(config)))
1573        }
1574    }
1575}
1576
1577#[cfg(feature = "tls-config")]
1578pub use rustls_config::{TlsClientConfig, TlsConfigError};
1579
1580#[cfg(feature = "tls-config")]
1581pub use rustls_config::TlsControl;
1582
1583// ── reqwest integration ──────────────────────────────────────────────────────
1584//
1585// Feature-gated behind `tls-config`. Builds a `reqwest::Client` that uses a
1586// TLS-profiled `ClientConfig` so that HTTP-only scraping paths present a
1587// browser-consistent TLS fingerprint.
1588
1589#[cfg(feature = "tls-config")]
1590mod reqwest_client {
1591    #[allow(clippy::wildcard_imports)]
1592    use super::*;
1593    use std::sync::Arc;
1594
1595    /// Error building a TLS-profiled reqwest client.
1596    #[derive(Debug, thiserror::Error)]
1597    #[non_exhaustive]
1598    pub enum TlsClientError {
1599        #[error(transparent)]
1600        TlsConfig(#[from] super::rustls_config::TlsConfigError),
1601
1602        /// reqwest rejected the builder configuration.
1603        #[error("reqwest client: {0}")]
1604        Reqwest(#[from] reqwest::Error),
1605    }
1606
1607    /// Return a User-Agent string that matches the given TLS profile's browser.
1608    ///
1609    /// Anti-bot systems cross-reference the `User-Agent` header against the
1610    /// TLS fingerprint. Sending a Chrome TLS profile with a Firefox `User-Agent`
1611    /// is a strong detection signal.
1612    ///
1613    /// # Matching logic
1614    ///
1615    /// | Profile name contains | User-Agent |
1616    /// |---|---|
1617    /// | `"Chrome"` | Chrome 131 on Windows 10 |
1618    /// | `"Firefox"` | Firefox 133 on Windows 10 |
1619    /// | `"Safari"` | Safari 18 on macOS 14.7 |
1620    /// | `"Edge"` | Edge 131 on Windows 10 |
1621    /// | *(other)* | Chrome 131 on Windows 10 (safe fallback) |
1622    pub fn default_user_agent(profile: &TlsProfile) -> &'static str {
1623        let name = profile.name.to_ascii_lowercase();
1624        if name.contains("firefox") {
1625            "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0"
1626        } else if name.contains("safari") && !name.contains("chrome") {
1627            "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15"
1628        } else if name.contains("edge") {
1629            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"
1630        } else {
1631            // Chrome is the default / fallback.
1632            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
1633        }
1634    }
1635
1636    /// Select the built-in [`TlsProfile`] that best matches a
1637    /// [`DeviceProfile`](crate::fingerprint::DeviceProfile).
1638    ///
1639    /// | Device | Selected Profile |
1640    /// |---|---|
1641    /// | `MobileAndroid` | [`CHROME_131`] |
1642    /// | `MobileIOS` | [`SAFARI_18`] |
1643    pub fn profile_for_device(device: &crate::fingerprint::DeviceProfile) -> &'static TlsProfile {
1644        use crate::fingerprint::DeviceProfile;
1645        match device {
1646            DeviceProfile::DesktopWindows | DeviceProfile::MobileAndroid => &CHROME_131,
1647            DeviceProfile::DesktopMac | DeviceProfile::MobileIOS => &SAFARI_18,
1648            DeviceProfile::DesktopLinux => &FIREFOX_133,
1649        }
1650    }
1651
1652    /// HTTP headers that match the browser identity of `profile`.
1653    ///
1654    /// Anti-bot systems cross-correlate HTTP headers (especially `Accept`,
1655    /// `Accept-Language`, `Accept-Encoding`, and the `Sec-CH-UA` family)
1656    /// against the TLS fingerprint. Mismatches between the TLS profile and
1657    /// the HTTP headers are a strong detection signal.
1658    ///
1659    /// of this type would send on a standard navigation request.
1660    ///
1661    /// # Example
1662    ///
1663    /// ```
1664    /// use stygian_browser::tls::{browser_headers, CHROME_131};
1665    ///
1666    /// let headers = browser_headers(&CHROME_131);
1667    /// assert!(headers.contains_key("accept"));
1668    /// ```
1669    pub fn browser_headers(profile: &TlsProfile) -> reqwest::header::HeaderMap {
1670        use reqwest::header::{
1671            ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, HeaderMap, HeaderValue,
1672            UPGRADE_INSECURE_REQUESTS,
1673        };
1674
1675        let mut map = HeaderMap::new();
1676        let name = profile.name.to_ascii_lowercase();
1677
1678        let is_firefox = name.contains("firefox");
1679        let is_safari = name.contains("safari") && !name.contains("chrome");
1680        let is_chromium = !(is_firefox || is_safari);
1681
1682        // Accept — differs between Chromium-family and Firefox/Safari.
1683        let accept = if is_chromium {
1684            // Chromium (Chrome / Edge)
1685            "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
1686        } else {
1687            "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1688        };
1689
1690        // Accept-Encoding — all modern browsers negotiate the same set.
1691        let accept_encoding = "gzip, deflate, br";
1692
1693        // Accept-Language — pick a realistic primary locale. Passive
1694        // fingerprinting rarely cares about the exact locale beyond the
1695        // primary tag, so en-US is a safe baseline.
1696        let accept_language = "en-US,en;q=0.9";
1697
1698        // Sec-CH-UA headers — Chromium-only.
1699        if is_chromium {
1700            let (brand, version) = if name.contains("edge") {
1701                ("\"Microsoft Edge\";v=\"131\"", "131")
1702            } else {
1703                ("\"Google Chrome\";v=\"131\"", "131")
1704            };
1705
1706            let sec_ch_ua =
1707                format!("{brand}, \"Chromium\";v=\"{version}\", \"Not_A Brand\";v=\"24\"");
1708
1709            // These headers are valid ASCII so HeaderValue::from_str can only
1710            // fail on control characters — which our strings never contain.
1711            if let Ok(v) = HeaderValue::from_str(&sec_ch_ua) {
1712                map.insert("sec-ch-ua", v);
1713            }
1714            map.insert("sec-ch-ua-mobile", HeaderValue::from_static("?0"));
1715            map.insert(
1716                "sec-ch-ua-platform",
1717                HeaderValue::from_static("\"Windows\""),
1718            );
1719            map.insert("sec-fetch-dest", HeaderValue::from_static("document"));
1720            map.insert("sec-fetch-mode", HeaderValue::from_static("navigate"));
1721            map.insert("sec-fetch-site", HeaderValue::from_static("none"));
1722            map.insert("sec-fetch-user", HeaderValue::from_static("?1"));
1723            map.insert(UPGRADE_INSECURE_REQUESTS, HeaderValue::from_static("1"));
1724        }
1725
1726        if let Ok(v) = HeaderValue::from_str(accept) {
1727            map.insert(ACCEPT, v);
1728        }
1729        map.insert(ACCEPT_ENCODING, HeaderValue::from_static(accept_encoding));
1730        map.insert(ACCEPT_LANGUAGE, HeaderValue::from_static(accept_language));
1731        map.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
1732
1733        map
1734    }
1735
1736    /// Build a [`reqwest::Client`] whose TLS `ClientHello` matches
1737    /// `profile`.
1738    ///
1739    /// The returned client:
1740    ///   (via [`default_user_agent`]).
1741    /// - Sets browser-matched HTTP headers via [`browser_headers`]
1742    ///   (`Accept`, `Accept-Encoding`, `Sec-CH-UA`, etc.).
1743    /// - Routes through `proxy_url` when provided.
1744    ///
1745    /// # Errors
1746    ///
1747    ///
1748    /// # Example
1749    ///
1750    /// ```no_run
1751    /// use stygian_browser::tls::{build_profiled_client, CHROME_131};
1752    ///
1753    /// let client = build_profiled_client(&CHROME_131, None).unwrap();
1754    /// ```
1755    pub fn build_profiled_client(
1756        profile: &TlsProfile,
1757        proxy_url: Option<&str>,
1758    ) -> Result<reqwest::Client, TlsClientError> {
1759        build_profiled_client_with_control(profile, proxy_url, TlsControl::default())
1760    }
1761
1762    /// Build a [`reqwest::Client`] using profile-specific control presets.
1763    ///
1764    /// without manually selecting [`TlsControl`] fields.
1765    ///
1766    /// # Example
1767    ///
1768    /// ```no_run
1769    /// use stygian_browser::tls::{build_profiled_client_preset, CHROME_131};
1770    ///
1771    /// let client = build_profiled_client_preset(&CHROME_131, None).unwrap();
1772    /// let _ = client;
1773    /// ```
1774    pub fn build_profiled_client_preset(
1775        profile: &TlsProfile,
1776        proxy_url: Option<&str>,
1777    ) -> Result<reqwest::Client, TlsClientError> {
1778        build_profiled_client_with_control(profile, proxy_url, TlsControl::for_profile(profile))
1779    }
1780
1781    /// Build a [`reqwest::Client`] with explicit TLS profile control settings.
1782    ///
1783    /// introducing native build dependencies.
1784    ///
1785    /// # Example
1786    ///
1787    /// ```no_run
1788    /// use stygian_browser::tls::{build_profiled_client_with_control, CHROME_131, TlsControl};
1789    ///
1790    /// let client = build_profiled_client_with_control(
1791    ///     &CHROME_131,
1792    ///     None,
1793    ///     TlsControl::strict(),
1794    /// ).unwrap();
1795    /// let _ = client;
1796    /// ```
1797    pub fn build_profiled_client_with_control(
1798        profile: &TlsProfile,
1799        proxy_url: Option<&str>,
1800        control: TlsControl,
1801    ) -> Result<reqwest::Client, TlsClientError> {
1802        let tls_config = profile.to_rustls_config_with_control(control)?;
1803
1804        let rustls_cfg =
1805            Arc::try_unwrap(tls_config.into_inner()).unwrap_or_else(|arc| (*arc).clone());
1806
1807        let mut builder = reqwest::Client::builder()
1808            .use_preconfigured_tls(rustls_cfg)
1809            .user_agent(default_user_agent(profile))
1810            .default_headers(browser_headers(profile))
1811            .cookie_store(true)
1812            .gzip(true)
1813            .brotli(true);
1814
1815        if let Some(url) = proxy_url {
1816            builder = builder.proxy(reqwest::Proxy::all(url)?);
1817        }
1818
1819        Ok(builder.build()?)
1820    }
1821
1822    /// Build a strict TLS-profiled [`reqwest::Client`].
1823    ///
1824    /// Strict mode rejects unsupported cipher suites instead of silently
1825    /// skipping them.
1826    ///
1827    /// # Example
1828    ///
1829    /// ```no_run
1830    /// use stygian_browser::tls::{build_profiled_client_strict, CHROME_131};
1831    ///
1832    /// let client = build_profiled_client_strict(&CHROME_131, None).unwrap();
1833    /// let _ = client;
1834    /// ```
1835    pub fn build_profiled_client_strict(
1836        profile: &TlsProfile,
1837        proxy_url: Option<&str>,
1838    ) -> Result<reqwest::Client, TlsClientError> {
1839        build_profiled_client_with_control(profile, proxy_url, TlsControl::strict())
1840    }
1841}
1842
1843#[cfg(feature = "tls-config")]
1844pub use reqwest_client::{
1845    TlsClientError, browser_headers, build_profiled_client, build_profiled_client_preset,
1846    build_profiled_client_strict, build_profiled_client_with_control, default_user_agent,
1847    profile_for_device,
1848};
1849
1850// ── tests ────────────────────────────────────────────────────────────────────
1851
1852#[cfg(test)]
1853#[allow(clippy::panic, clippy::unwrap_used)]
1854mod tests {
1855    use super::*;
1856
1857    #[test]
1858    fn md5_known_vectors() {
1859        assert_eq!(md5_hex(b""), "d41d8cd98f00b204e9800998ecf8427e");
1860        assert_eq!(md5_hex(b"a"), "0cc175b9c0f1b6a831c399e269772661");
1861        assert_eq!(md5_hex(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
1862        assert_eq!(
1863            md5_hex(b"message digest"),
1864            "f96b697d7cb7938d525a2f31aaf161d0"
1865        );
1866    }
1867
1868    #[test]
1869    fn chrome_131_ja3_structure() {
1870        let ja3 = CHROME_131.ja3();
1871        // Must start with 771 (TLS 1.2 = 0x0303 = 771 is the *highest* in
1872        // the supported list, but TLS 1.3 = 0x0304 = 772 is also present;
1873        // ja3 picks max → 772).
1874        assert!(
1875            ja3.raw.starts_with("772,"),
1876            "JA3 raw should start with '772,' but was: {}",
1877            ja3.raw
1878        );
1879        // Has five comma-separated sections.
1880        assert_eq!(ja3.raw.matches(',').count(), 4);
1881        // Hash is 32 hex chars.
1882        assert_eq!(ja3.hash.len(), 32);
1883        assert!(ja3.hash.chars().all(|c| c.is_ascii_hexdigit()));
1884    }
1885
1886    #[test]
1887    fn firefox_133_ja3_differs_from_chrome() {
1888        let chrome_ja3 = CHROME_131.ja3();
1889        let firefox_ja3 = FIREFOX_133.ja3();
1890        assert_ne!(chrome_ja3.hash, firefox_ja3.hash);
1891        assert_ne!(chrome_ja3.raw, firefox_ja3.raw);
1892    }
1893
1894    #[test]
1895    fn safari_18_ja3_is_valid() {
1896        let ja3 = SAFARI_18.ja3();
1897        assert!(ja3.raw.starts_with("772,"));
1898        assert_eq!(ja3.hash.len(), 32);
1899    }
1900
1901    #[test]
1902    fn edge_131_ja3_differs_from_chrome() {
1903        let chrome_ja3 = CHROME_131.ja3();
1904        let edge_ja3 = EDGE_131.ja3();
1905        assert_ne!(chrome_ja3.hash, edge_ja3.hash);
1906    }
1907
1908    #[test]
1909    fn chrome_131_ja4_format() {
1910        let ja4 = CHROME_131.ja4();
1911        // Starts with 't13d' (TCP, TLS 1.3, domain SNI).
1912        assert!(
1913            ja4.fingerprint.starts_with("t13d"),
1914            "JA4 should start with 't13d' but was: {}",
1915            ja4.fingerprint
1916        );
1917        // Three underscore-separated sections.
1918        assert_eq!(
1919            ja4.fingerprint.matches('_').count(),
1920            3,
1921            "JA4 should have three separators: {}",
1922            ja4.fingerprint
1923        );
1924    }
1925
1926    #[test]
1927    fn ja4_firefox_differs_from_chrome() {
1928        let chrome_ja4 = CHROME_131.ja4();
1929        let firefox_ja4 = FIREFOX_133.ja4();
1930        assert_ne!(chrome_ja4.fingerprint, firefox_ja4.fingerprint);
1931    }
1932
1933    #[test]
1934    fn random_weighted_distribution() {
1935        let mut chrome_count = 0u32;
1936        let mut firefox_count = 0u32;
1937        let mut edge_count = 0u32;
1938        let mut safari_count = 0u32;
1939
1940        let total = 10_000u32;
1941        for i in 0..total {
1942            let profile = TlsProfile::random_weighted(u64::from(i));
1943            match profile.name.as_str() {
1944                "Chrome 131" => chrome_count += 1,
1945                "Firefox 133" => firefox_count += 1,
1946                "Edge 131" => edge_count += 1,
1947                "Safari 18" => safari_count += 1,
1948                other => unreachable!("unexpected profile: {other}"),
1949            }
1950        }
1951
1952        // Chrome should be the most common (>40%).
1953        assert!(
1954            chrome_count > total * 40 / 100,
1955            "Chrome share too low: {chrome_count}/{total}"
1956        );
1957        // Firefox should appear (>5%).
1958        assert!(
1959            firefox_count > total * 5 / 100,
1960            "Firefox share too low: {firefox_count}/{total}"
1961        );
1962        // Edge should appear (>5%).
1963        assert!(
1964            edge_count > total * 5 / 100,
1965            "Edge share too low: {edge_count}/{total}"
1966        );
1967        // Safari should appear (>3%).
1968        assert!(
1969            safari_count > total * 3 / 100,
1970            "Safari share too low: {safari_count}/{total}"
1971        );
1972    }
1973
1974    #[test]
1975    fn serde_roundtrip() {
1976        let profile: &TlsProfile = &CHROME_131;
1977        let json = serde_json::to_string(profile).unwrap();
1978        let deserialized: TlsProfile = serde_json::from_str(&json).unwrap();
1979        assert_eq!(profile, &deserialized);
1980    }
1981
1982    #[test]
1983    fn ja3hash_display() {
1984        let ja3 = CHROME_131.ja3();
1985        assert_eq!(format!("{ja3}"), ja3.hash);
1986    }
1987
1988    #[test]
1989    fn ja4_display() {
1990        let ja4 = CHROME_131.ja4();
1991        assert_eq!(format!("{ja4}"), ja4.fingerprint);
1992    }
1993
1994    #[test]
1995    fn http3_perk_chrome_text_and_hash_are_stable() {
1996        let Some(perk) = CHROME_131.http3_perk() else {
1997            panic!("chrome should have perk");
1998        };
1999        let text = perk.perk_text();
2000        assert_eq!(text, "1:65536;6:262144;7:100;51:1;GREASE|masp");
2001        assert_eq!(perk.perk_hash().len(), 32);
2002    }
2003
2004    #[test]
2005    fn expected_perk_from_user_agent_detects_firefox() {
2006        let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0";
2007        let Some(perk) = expected_http3_perk_from_user_agent(ua) else {
2008            panic!("firefox should resolve");
2009        };
2010        assert_eq!(perk.perk_text(), "1:65536;7:20;727725890:0|mpas");
2011    }
2012
2013    #[test]
2014    fn http3_perk_compare_detects_text_mismatch() {
2015        let Some(perk) = CHROME_131.http3_perk() else {
2016            panic!("chrome should have perk");
2017        };
2018        let cmp = perk.compare(Some("1:65536|masp"), None);
2019        assert!(!cmp.matches);
2020        assert_eq!(cmp.mismatches.len(), 1);
2021        assert!(
2022            cmp.mismatches
2023                .first()
2024                .is_some_and(|mismatch| mismatch.contains("perk_text mismatch"))
2025        );
2026    }
2027
2028    #[test]
2029    fn cipher_suite_display() {
2030        let cs = CipherSuiteId::TLS_AES_128_GCM_SHA256;
2031        assert_eq!(format!("{cs}"), "4865"); // 0x1301 = 4865
2032    }
2033
2034    #[test]
2035    fn tls_version_display() {
2036        assert_eq!(format!("{}", TlsVersion::Tls13), "772");
2037    }
2038
2039    #[test]
2040    fn alpn_protocol_as_str() {
2041        assert_eq!(AlpnProtocol::H2.as_str(), "h2");
2042        assert_eq!(AlpnProtocol::Http11.as_str(), "http/1.1");
2043    }
2044
2045    #[test]
2046    fn supported_group_values() {
2047        assert_eq!(SupportedGroup::X25519.iana_value(), 0x001d);
2048        assert_eq!(SupportedGroup::SecP256r1.iana_value(), 0x0017);
2049        assert_eq!(SupportedGroup::X25519Kyber768.iana_value(), 0x6399);
2050    }
2051
2052    // ── Chrome TLS flags tests ─────────────────────────────────────────
2053
2054    #[test]
2055    fn chrome_131_tls_args_empty() {
2056        // Chrome 131 supports both TLS 1.2 and 1.3 — no extra flags needed.
2057        let args = chrome_tls_args(&CHROME_131);
2058        assert!(args.is_empty(), "expected no flags, got: {args:?}");
2059    }
2060
2061    #[test]
2062    fn tls12_only_profile_caps_version() {
2063        let profile = TlsProfile {
2064            name: "TLS12-only".to_string(),
2065            cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2066            tls_versions: vec![TlsVersion::Tls12],
2067            extensions: vec![],
2068            supported_groups: vec![],
2069            signature_algorithms: vec![],
2070            alpn_protocols: vec![],
2071        };
2072        let args = chrome_tls_args(&profile);
2073        assert_eq!(args, vec!["--ssl-version-max=tls1.2"]);
2074    }
2075
2076    #[test]
2077    fn tls13_only_profile_raises_floor() {
2078        let profile = TlsProfile {
2079            name: "TLS13-only".to_string(),
2080            cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2081            tls_versions: vec![TlsVersion::Tls13],
2082            extensions: vec![],
2083            supported_groups: vec![],
2084            signature_algorithms: vec![],
2085            alpn_protocols: vec![],
2086        };
2087        let args = chrome_tls_args(&profile);
2088        assert_eq!(args, vec!["--ssl-version-min=tls1.3"]);
2089    }
2090
2091    #[test]
2092    fn builder_tls_profile_integration() {
2093        let cfg = crate::BrowserConfig::builder()
2094            .tls_profile(&CHROME_131)
2095            .build();
2096        // Chrome 131 has both versions — no TLS flags added.
2097        let tls_flags: Vec<_> = cfg
2098            .effective_args()
2099            .into_iter()
2100            .filter(|a| a.starts_with("--ssl-version"))
2101            .collect();
2102        assert!(tls_flags.is_empty(), "unexpected TLS flags: {tls_flags:?}");
2103    }
2104
2105    // ── rustls integration tests ─────────────────────────────────────────
2106
2107    #[cfg(feature = "tls-config")]
2108    mod rustls_tests {
2109        use super::super::*;
2110
2111        #[test]
2112        fn chrome_131_config_builds_successfully() {
2113            let config = CHROME_131.to_rustls_config().unwrap();
2114            // The inner ClientConfig should be accessible.
2115            let inner = config.inner();
2116            // ALPN must be set.
2117            assert!(
2118                !inner.alpn_protocols.is_empty(),
2119                "ALPN protocols should be set"
2120            );
2121        }
2122
2123        #[test]
2124        #[allow(clippy::indexing_slicing)]
2125        fn alpn_order_matches_profile() {
2126            let config = CHROME_131.to_rustls_config().unwrap();
2127            let alpn = &config.inner().alpn_protocols;
2128            assert_eq!(alpn.len(), 2);
2129            assert_eq!(alpn[0], b"h2");
2130            assert_eq!(alpn[1], b"http/1.1");
2131        }
2132
2133        #[test]
2134        fn all_builtin_profiles_produce_valid_configs() {
2135            for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2136                let result = profile.to_rustls_config();
2137                assert!(
2138                    result.is_ok(),
2139                    "profile '{}' should produce a valid config: {:?}",
2140                    profile.name,
2141                    result.err()
2142                );
2143            }
2144        }
2145
2146        #[test]
2147        fn unsupported_only_suites_returns_error() {
2148            let profile = TlsProfile {
2149                name: "Bogus".to_string(),
2150                cipher_suites: vec![CipherSuiteId(0xFFFF)],
2151                tls_versions: vec![TlsVersion::Tls13],
2152                extensions: vec![],
2153                supported_groups: vec![],
2154                signature_algorithms: vec![],
2155                alpn_protocols: vec![],
2156            };
2157            let err = profile.to_rustls_config().unwrap_err();
2158            assert!(
2159                err.to_string().contains("no supported cipher suites"),
2160                "expected NoCipherSuites, got: {err}"
2161            );
2162        }
2163
2164        #[test]
2165        fn strict_mode_rejects_unknown_cipher_suite() {
2166            let profile = TlsProfile {
2167                name: "StrictCipherTest".to_string(),
2168                cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256, CipherSuiteId(0xFFFF)],
2169                tls_versions: vec![TlsVersion::Tls13],
2170                extensions: vec![],
2171                supported_groups: vec![SupportedGroup::X25519],
2172                signature_algorithms: vec![],
2173                alpn_protocols: vec![],
2174            };
2175
2176            let err = profile
2177                .to_rustls_config_with_control(TlsControl::strict())
2178                .unwrap_err();
2179
2180            match err {
2181                TlsConfigError::UnsupportedCipherSuite {
2182                    cipher_suite_id, ..
2183                } => {
2184                    assert_eq!(cipher_suite_id, 0xFFFF);
2185                }
2186                other => panic!("expected UnsupportedCipherSuite, got: {other}"),
2187            }
2188        }
2189
2190        #[test]
2191        fn compatible_mode_skips_unknown_cipher_suite() {
2192            let mut profile = (*CHROME_131).clone();
2193            profile.cipher_suites.push(CipherSuiteId(0xFFFF));
2194
2195            let cfg = profile.to_rustls_config_with_control(TlsControl::compatible());
2196            assert!(cfg.is_ok(), "compatible mode should skip unknown suite");
2197        }
2198
2199        #[test]
2200        fn control_for_builtin_profiles_is_strict() {
2201            for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2202                let control = TlsControl::for_profile(profile);
2203                assert!(
2204                    control.strict_cipher_suites,
2205                    "builtin profile '{}' should use strict cipher checking",
2206                    profile.name
2207                );
2208            }
2209        }
2210
2211        #[test]
2212        fn control_for_custom_profile_is_compatible() {
2213            let profile = TlsProfile {
2214                name: "Custom Backend".to_string(),
2215                cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2216                tls_versions: vec![TlsVersion::Tls13],
2217                extensions: vec![],
2218                supported_groups: vec![SupportedGroup::X25519],
2219                signature_algorithms: vec![],
2220                alpn_protocols: vec![],
2221            };
2222
2223            let control = TlsControl::for_profile(&profile);
2224            assert!(!control.strict_cipher_suites);
2225            assert!(!control.strict_supported_groups);
2226            assert!(control.fallback_to_provider_groups);
2227        }
2228
2229        #[test]
2230        fn strict_all_without_groups_returns_error() {
2231            let profile = TlsProfile {
2232                name: "StrictGroupTest".to_string(),
2233                cipher_suites: vec![CipherSuiteId::TLS_AES_128_GCM_SHA256],
2234                tls_versions: vec![TlsVersion::Tls13],
2235                extensions: vec![],
2236                supported_groups: vec![],
2237                signature_algorithms: vec![],
2238                alpn_protocols: vec![],
2239            };
2240
2241            let err = profile
2242                .to_rustls_config_with_control(TlsControl::strict_all())
2243                .unwrap_err();
2244
2245            match err {
2246                TlsConfigError::NoSupportedGroups(name) => {
2247                    assert_eq!(name, "StrictGroupTest");
2248                }
2249                other => panic!("expected NoSupportedGroups, got: {other}"),
2250            }
2251        }
2252
2253        #[test]
2254        fn into_arc_conversion() {
2255            let config = CHROME_131.to_rustls_config().unwrap();
2256            let arc: std::sync::Arc<rustls::ClientConfig> = config.into();
2257            // Should be valid — just verify it doesn't panic.
2258            assert!(!arc.alpn_protocols.is_empty());
2259        }
2260    }
2261
2262    // ── reqwest client tests ─────────────────────────────────────────
2263
2264    #[cfg(feature = "tls-config")]
2265    mod reqwest_tests {
2266        use super::super::*;
2267
2268        #[test]
2269        fn build_profiled_client_no_proxy() {
2270            let client = build_profiled_client(&CHROME_131, None);
2271            assert!(
2272                client.is_ok(),
2273                "should build a client without error: {:?}",
2274                client.err()
2275            );
2276        }
2277
2278        #[test]
2279        fn build_profiled_client_all_profiles() {
2280            for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2281                let result = build_profiled_client(profile, None);
2282                assert!(
2283                    result.is_ok(),
2284                    "profile '{}' should produce a valid client: {:?}",
2285                    profile.name,
2286                    result.err()
2287                );
2288            }
2289        }
2290
2291        #[test]
2292        fn build_profiled_client_strict_no_proxy() {
2293            let client = build_profiled_client_strict(&CHROME_131, None);
2294            assert!(
2295                client.is_ok(),
2296                "strict mode should build for built-in profile: {:?}",
2297                client.err()
2298            );
2299        }
2300
2301        #[test]
2302        fn build_profiled_client_preset_all_profiles() {
2303            for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2304                let result = build_profiled_client_preset(profile, None);
2305                assert!(
2306                    result.is_ok(),
2307                    "preset builder should work for profile '{}': {:?}",
2308                    profile.name,
2309                    result.err()
2310                );
2311            }
2312        }
2313
2314        #[test]
2315        fn build_profiled_client_with_control_rejects_unknown_cipher_suite() {
2316            let mut profile = (*CHROME_131).clone();
2317            profile.cipher_suites.push(CipherSuiteId(0xFFFF));
2318
2319            let client = build_profiled_client_with_control(&profile, None, TlsControl::strict());
2320
2321            assert!(
2322                client.is_err(),
2323                "strict mode should reject unsupported cipher suite"
2324            );
2325        }
2326
2327        #[test]
2328        fn default_user_agent_matches_browser() {
2329            assert!(default_user_agent(&CHROME_131).contains("Chrome/131"));
2330            assert!(default_user_agent(&FIREFOX_133).contains("Firefox/133"));
2331            assert!(default_user_agent(&SAFARI_18).contains("Safari/605"));
2332            assert!(default_user_agent(&EDGE_131).contains("Edg/131"));
2333        }
2334
2335        #[test]
2336        fn profile_for_device_mapping() {
2337            use crate::fingerprint::DeviceProfile;
2338
2339            assert_eq!(
2340                profile_for_device(&DeviceProfile::DesktopWindows).name,
2341                "Chrome 131"
2342            );
2343            assert_eq!(
2344                profile_for_device(&DeviceProfile::DesktopMac).name,
2345                "Safari 18"
2346            );
2347            assert_eq!(
2348                profile_for_device(&DeviceProfile::DesktopLinux).name,
2349                "Firefox 133"
2350            );
2351            assert_eq!(
2352                profile_for_device(&DeviceProfile::MobileAndroid).name,
2353                "Chrome 131"
2354            );
2355            assert_eq!(
2356                profile_for_device(&DeviceProfile::MobileIOS).name,
2357                "Safari 18"
2358            );
2359        }
2360
2361        #[test]
2362        fn browser_headers_chrome_has_sec_ch_ua() {
2363            let headers = browser_headers(&CHROME_131);
2364            assert!(
2365                headers.contains_key("sec-ch-ua"),
2366                "Chrome profile should have sec-ch-ua"
2367            );
2368            assert!(
2369                headers.contains_key("sec-fetch-dest"),
2370                "Chrome profile should have sec-fetch-dest"
2371            );
2372            let accept = headers.get("accept").unwrap().to_str().unwrap();
2373            assert!(
2374                accept.contains("image/avif"),
2375                "Chrome accept should include avif"
2376            );
2377        }
2378
2379        #[test]
2380        fn browser_headers_firefox_no_sec_ch_ua() {
2381            let headers = browser_headers(&FIREFOX_133);
2382            assert!(
2383                !headers.contains_key("sec-ch-ua"),
2384                "Firefox profile should not have sec-ch-ua"
2385            );
2386            let accept = headers.get("accept").unwrap().to_str().unwrap();
2387            assert!(
2388                accept.contains("text/html"),
2389                "Firefox accept should include text/html"
2390            );
2391        }
2392
2393        #[test]
2394        fn browser_headers_all_profiles_have_accept() {
2395            for profile in [&*CHROME_131, &*FIREFOX_133, &*SAFARI_18, &*EDGE_131] {
2396                let headers = browser_headers(profile);
2397                assert!(
2398                    headers.contains_key("accept"),
2399                    "profile '{}' must have accept header",
2400                    profile.name
2401                );
2402                assert!(
2403                    headers.contains_key("accept-encoding"),
2404                    "profile '{}' must have accept-encoding",
2405                    profile.name
2406                );
2407                assert!(
2408                    headers.contains_key("accept-language"),
2409                    "profile '{}' must have accept-language",
2410                    profile.name
2411                );
2412            }
2413        }
2414
2415        #[test]
2416        fn browser_headers_edge_uses_edge_brand() {
2417            let headers = browser_headers(&EDGE_131);
2418            let sec_ch_ua = headers.get("sec-ch-ua").unwrap().to_str().unwrap();
2419            assert!(
2420                sec_ch_ua.contains("Microsoft Edge"),
2421                "Edge sec-ch-ua should identify Edge: {sec_ch_ua}"
2422            );
2423        }
2424    }
2425}