Skip to main content

stygian_browser/
tls.rs

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