Skip to main content

hpx_browser/net/
headers.rs

1//! Ordered browser header construction for Chrome/Firefox/Safari.
2//!
3//! Anti-bot systems check both the presence and order of HTTP headers.
4//! This module builds headers in the exact order each browser sends them.
5
6use crate::stealth::{DeviceClass, StealthProfile};
7
8/// Browser-aware nav header dispatch.
9pub fn nav_headers(profile: &StealthProfile, accept_ch_upgraded: bool) -> Vec<(String, String)> {
10    match profile.browser_name.as_str() {
11        "Firefox" => firefox_headers(profile),
12        "Safari" => safari_headers(profile),
13        _ if accept_ch_upgraded => chrome_headers_with_accept_ch(profile),
14        _ => chrome_headers(profile),
15    }
16}
17
18/// URL-aware nav header dispatch with per-region accept-language.
19pub fn nav_headers_for_url(
20    profile: &StealthProfile,
21    url: &str,
22    accept_ch_upgraded: bool,
23) -> Vec<(String, String)> {
24    let mut hdrs = nav_headers(profile, accept_ch_upgraded);
25    apply_region_accept_language(&mut hdrs, url, &profile.browser_name);
26    hdrs
27}
28
29/// Replace `accept-language` in `hdrs` with the region-appropriate value.
30pub fn apply_region_accept_language(hdrs: &mut [(String, String)], url: &str, browser_name: &str) {
31    let Some(langs) = region_languages_for_url(url) else {
32        return;
33    };
34    let value = match browser_name {
35        "Firefox" => build_firefox_accept_language(&langs),
36        "Safari" => build_safari_accept_language(&langs),
37        _ => build_accept_language(&langs),
38    };
39    for (k, v) in hdrs.iter_mut() {
40        if k.eq_ignore_ascii_case("accept-language") {
41            *v = value;
42            return;
43        }
44    }
45}
46
47/// Per-TLD regional language list.
48pub fn region_languages_for_url(url: &str) -> Option<Vec<String>> {
49    let parsed = url::Url::parse(url).ok()?;
50    let host = parsed.host_str()?.to_ascii_lowercase();
51    let host = host.trim_start_matches("www.");
52    let tld = if host.ends_with(".co.jp") {
53        ".co.jp"
54    } else if host.ends_with(".com.br") {
55        ".com.br"
56    } else if host.ends_with(".com.mx") {
57        ".com.mx"
58    } else if host.ends_with(".com.tr") {
59        ".com.tr"
60    } else if host.ends_with(".com.cn") {
61        ".com.cn"
62    } else {
63        let dot = host.rfind('.')?;
64        &host[dot..]
65    };
66    let langs: &[&str] = match tld {
67        ".fr" => &["fr-FR", "fr", "en-US", "en"],
68        ".de" => &["de-DE", "de", "en-US", "en"],
69        ".co.jp" | ".jp" => &["ja-JP", "ja", "en-US", "en"],
70        ".it" => &["it-IT", "it", "en-US", "en"],
71        ".es" => &["es-ES", "es", "en-US", "en"],
72        ".nl" => &["nl-NL", "nl", "en-US", "en"],
73        ".pl" => &["pl-PL", "pl", "en-US", "en"],
74        ".se" => &["sv-SE", "sv", "en-US", "en"],
75        ".no" => &["nb-NO", "no", "en-US", "en"],
76        ".dk" => &["da-DK", "da", "en-US", "en"],
77        ".fi" => &["fi-FI", "fi", "en-US", "en"],
78        ".pt" => &["pt-PT", "pt", "en-US", "en"],
79        ".com.br" => &["pt-BR", "pt", "en-US", "en"],
80        ".com.mx" => &["es-MX", "es", "en-US", "en"],
81        ".com.tr" | ".tr" => &["tr-TR", "tr", "en-US", "en"],
82        ".com.cn" | ".cn" => &["zh-CN", "zh", "en-US", "en"],
83        ".ru" => &["ru-RU", "ru", "en-US", "en"],
84        ".kr" => &["ko-KR", "ko", "en-US", "en"],
85        ".tw" => &["zh-TW", "zh", "en-US", "en"],
86        ".vn" => &["vi-VN", "vi", "en-US", "en"],
87        _ => return None,
88    };
89    Some(langs.iter().map(|s| (*s).to_string()).collect())
90}
91
92/// Browser-aware reload nav header dispatch.
93pub fn nav_headers_reload(
94    profile: &StealthProfile,
95    referer: &str,
96    accept_ch_upgraded: bool,
97) -> Vec<(String, String)> {
98    match profile.browser_name.as_str() {
99        "Firefox" => firefox_headers_reload(profile, referer),
100        "Safari" => safari_headers_reload(profile, referer),
101        _ => chrome_headers_reload(profile, referer, accept_ch_upgraded),
102    }
103}
104
105/// Browser-aware fetch (XHR/`window.fetch`) header dispatch.
106pub fn nav_headers_fetch(
107    profile: &StealthProfile,
108    target_url: &str,
109    origin: Option<&str>,
110) -> Vec<(String, String)> {
111    let mut hdrs = match profile.browser_name.as_str() {
112        "Firefox" => firefox_headers_fetch(profile, target_url, origin),
113        "Safari" => safari_headers_fetch(profile, target_url, origin),
114        _ => chrome_headers_fetch(profile, target_url, origin),
115    };
116    let key_url = origin.unwrap_or(target_url);
117    apply_region_accept_language(&mut hdrs, key_url, &profile.browser_name);
118    hdrs
119}
120
121// ============================================================================
122// Chrome header builders
123// ============================================================================
124
125pub fn chrome_headers(profile: &StealthProfile) -> Vec<(String, String)> {
126    chrome_headers_impl(profile, false)
127}
128
129pub fn chrome_headers_with_accept_ch(profile: &StealthProfile) -> Vec<(String, String)> {
130    chrome_headers_impl(profile, true)
131}
132
133pub fn chrome_headers_reload(
134    profile: &StealthProfile,
135    referer: &str,
136    accept_ch_upgraded: bool,
137) -> Vec<(String, String)> {
138    let mut hdrs: Vec<(String, String)> = chrome_headers_impl(profile, accept_ch_upgraded)
139        .into_iter()
140        .filter(|(k, _)| k != "sec-fetch-user")
141        .map(|(k, v)| {
142            if k == "sec-fetch-site" {
143                (k, "same-origin".to_string())
144            } else {
145                (k, v)
146            }
147        })
148        .collect();
149    hdrs.push(("referer".to_string(), referer.to_string()));
150    hdrs
151}
152
153pub fn chrome_headers_fetch(
154    profile: &StealthProfile,
155    target_url: &str,
156    origin: Option<&str>,
157) -> Vec<(String, String)> {
158    let mut headers = Vec::with_capacity(12);
159
160    headers.push(("user-agent".to_string(), profile.user_agent.clone()));
161    headers.push(("accept".to_string(), "*/*".to_string()));
162
163    let sec_ch_ua = build_sec_ch_ua(profile);
164    headers.push(("sec-ch-ua".to_string(), sec_ch_ua));
165    let is_mobile = matches!(
166        profile.device_class,
167        DeviceClass::MobileAndroid | DeviceClass::MobileIOS
168    );
169    headers.push((
170        "sec-ch-ua-mobile".to_string(),
171        if is_mobile { "?1" } else { "?0" }.to_string(),
172    ));
173    headers.push((
174        "sec-ch-ua-platform".to_string(),
175        format!("\"{}\"", profile.os_name),
176    ));
177
178    let site = match origin {
179        Some(o) => compute_sec_fetch_site(target_url, o),
180        None => "cross-site",
181    };
182    headers.push(("sec-fetch-site".to_string(), site.to_string()));
183    headers.push(("sec-fetch-mode".to_string(), "cors".to_string()));
184    headers.push(("sec-fetch-dest".to_string(), "empty".to_string()));
185
186    headers.push((
187        "accept-encoding".to_string(),
188        "gzip, deflate, br, zstd".to_string(),
189    ));
190    headers.push((
191        "accept-language".to_string(),
192        build_accept_language(&profile.languages),
193    ));
194    headers.push(("priority".to_string(), "u=1, i".to_string()));
195
196    if let Some(o) = origin {
197        headers.push(("origin".to_string(), o.to_string()));
198        headers.push((
199            "referer".to_string(),
200            format!("{}/", o.trim_end_matches('/')),
201        ));
202    }
203
204    headers
205}
206
207fn chrome_headers_impl(
208    profile: &StealthProfile,
209    include_high_entropy: bool,
210) -> Vec<(String, String)> {
211    let mut headers = Vec::with_capacity(if include_high_entropy { 20 } else { 13 });
212
213    // 1. sec-ch-ua
214    let sec_ch_ua = build_sec_ch_ua(profile);
215    headers.push(("sec-ch-ua".to_string(), sec_ch_ua));
216    let is_mobile = matches!(
217        profile.device_class,
218        DeviceClass::MobileAndroid | DeviceClass::MobileIOS
219    );
220    // 2. sec-ch-ua-mobile
221    headers.push((
222        "sec-ch-ua-mobile".to_string(),
223        if is_mobile { "?1" } else { "?0" }.to_string(),
224    ));
225    // 3. sec-ch-ua-platform
226    headers.push((
227        "sec-ch-ua-platform".to_string(),
228        format!("\"{}\"", profile.os_name),
229    ));
230
231    if include_high_entropy {
232        headers.push((
233            "sec-ch-ua-arch".to_string(),
234            format!("\"{}\"", profile.cpu_architecture),
235        ));
236        headers.push((
237            "sec-ch-ua-bitness".to_string(),
238            format!("\"{}\"", profile.cpu_bitness),
239        ));
240        headers.push((
241            "sec-ch-ua-full-version-list".to_string(),
242            build_sec_ch_ua_full_version_list(profile),
243        ));
244        headers.push((
245            "sec-ch-ua-full-version".to_string(),
246            format!("\"{}\"", profile.browser_version),
247        ));
248        headers.push((
249            "sec-ch-ua-model".to_string(),
250            format!("\"{}\"", profile.ua_model),
251        ));
252        headers.push((
253            "sec-ch-ua-platform-version".to_string(),
254            format!(
255                "\"{}\"",
256                chrome_platform_version(&profile.os_name, &profile.os_version)
257            ),
258        ));
259        headers.push((
260            "sec-ch-ua-wow64".to_string(),
261            if profile.ua_wow64 { "?1" } else { "?0" }.to_string(),
262        ));
263        headers.push((
264            "sec-ch-ua-form-factors".to_string(),
265            if is_mobile {
266                "\"Mobile\""
267            } else {
268                "\"Desktop\""
269            }
270            .to_string(),
271        ));
272        headers.push((
273            "sec-ch-device-memory".to_string(),
274            format!(
275                "{}",
276                quantize_device_memory(f64::from(profile.device_memory))
277            ),
278        ));
279    }
280
281    // 4. upgrade-insecure-requests
282    headers.push(("upgrade-insecure-requests".to_string(), "1".to_string()));
283    // 5. user-agent
284    headers.push(("user-agent".to_string(), profile.user_agent.clone()));
285    // 6. accept
286    headers.push(("accept".to_string(),
287        "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".to_string(),
288    ));
289    // 7. sec-fetch headers
290    headers.push(("sec-fetch-site".to_string(), "none".to_string()));
291    headers.push(("sec-fetch-mode".to_string(), "navigate".to_string()));
292    headers.push(("sec-fetch-user".to_string(), "?1".to_string()));
293    headers.push(("sec-fetch-dest".to_string(), "document".to_string()));
294    // 8. accept-encoding
295    headers.push((
296        "accept-encoding".to_string(),
297        "gzip, deflate, br, zstd".to_string(),
298    ));
299    // 9. accept-language
300    headers.push((
301        "accept-language".to_string(),
302        build_accept_language(&profile.languages),
303    ));
304    // 10. priority
305    headers.push(("priority".to_string(), "u=0, i".to_string()));
306
307    headers
308}
309
310// ============================================================================
311// Firefox header builders
312// ============================================================================
313
314pub fn firefox_headers(profile: &StealthProfile) -> Vec<(String, String)> {
315    firefox_headers_impl(profile, "none", true)
316}
317
318pub fn firefox_headers_reload(profile: &StealthProfile, referer: &str) -> Vec<(String, String)> {
319    let mut hdrs = firefox_headers_impl(profile, "same-origin", false);
320    hdrs.push(("referer".to_string(), referer.to_string()));
321    hdrs
322}
323
324pub fn firefox_headers_fetch(
325    profile: &StealthProfile,
326    target_url: &str,
327    origin: Option<&str>,
328) -> Vec<(String, String)> {
329    let mut headers = Vec::with_capacity(10);
330    headers.push(("user-agent".to_string(), profile.user_agent.clone()));
331    headers.push(("accept".to_string(), "*/*".to_string()));
332    headers.push((
333        "accept-language".to_string(),
334        build_firefox_accept_language(&profile.languages),
335    ));
336    headers.push((
337        "accept-encoding".to_string(),
338        "gzip, deflate, br, zstd".to_string(),
339    ));
340
341    let site = match origin {
342        Some(o) => compute_sec_fetch_site(target_url, o),
343        None => "cross-site",
344    };
345    headers.push(("sec-fetch-dest".to_string(), "empty".to_string()));
346    headers.push(("sec-fetch-mode".to_string(), "cors".to_string()));
347    headers.push(("sec-fetch-site".to_string(), site.to_string()));
348
349    if let Some(o) = origin {
350        headers.push(("origin".to_string(), o.to_string()));
351        headers.push((
352            "referer".to_string(),
353            format!("{}/", o.trim_end_matches('/')),
354        ));
355    }
356
357    headers
358}
359
360fn firefox_headers_impl(
361    profile: &StealthProfile,
362    sec_fetch_site: &str,
363    include_sec_fetch_user: bool,
364) -> Vec<(String, String)> {
365    let mut headers = Vec::with_capacity(9);
366
367    headers.push(("user-agent".to_string(), profile.user_agent.clone()));
368    headers.push((
369        "accept".to_string(),
370        "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string(),
371    ));
372    headers.push((
373        "accept-language".to_string(),
374        build_firefox_accept_language(&profile.languages),
375    ));
376    headers.push((
377        "accept-encoding".to_string(),
378        "gzip, deflate, br, zstd".to_string(),
379    ));
380    headers.push(("upgrade-insecure-requests".to_string(), "1".to_string()));
381    headers.push(("sec-fetch-dest".to_string(), "document".to_string()));
382    headers.push(("sec-fetch-mode".to_string(), "navigate".to_string()));
383    headers.push(("sec-fetch-site".to_string(), sec_fetch_site.to_string()));
384    if include_sec_fetch_user {
385        headers.push(("sec-fetch-user".to_string(), "?1".to_string()));
386    }
387
388    headers
389}
390
391// ============================================================================
392// Safari header builders
393// ============================================================================
394
395pub fn safari_headers(profile: &StealthProfile) -> Vec<(String, String)> {
396    safari_headers_impl(profile, None)
397}
398
399pub fn safari_headers_reload(profile: &StealthProfile, referer: &str) -> Vec<(String, String)> {
400    safari_headers_impl(profile, Some(referer))
401}
402
403pub fn safari_headers_fetch(
404    profile: &StealthProfile,
405    target_url: &str,
406    origin: Option<&str>,
407) -> Vec<(String, String)> {
408    let mut headers = Vec::with_capacity(7);
409    headers.push(("accept".to_string(), "*/*".to_string()));
410    headers.push((
411        "accept-language".to_string(),
412        build_safari_accept_language(&profile.languages),
413    ));
414    headers.push((
415        "accept-encoding".to_string(),
416        "gzip, deflate, br".to_string(),
417    ));
418    headers.push(("user-agent".to_string(), profile.user_agent.clone()));
419    if let Some(o) = origin {
420        headers.push(("origin".to_string(), o.to_string()));
421        headers.push((
422            "referer".to_string(),
423            format!("{}/", o.trim_end_matches('/')),
424        ));
425    }
426    let _ = target_url;
427    headers
428}
429
430fn safari_headers_impl(profile: &StealthProfile, referer: Option<&str>) -> Vec<(String, String)> {
431    let mut headers = Vec::with_capacity(9);
432
433    headers.push(("sec-fetch-dest".to_string(), "document".to_string()));
434    headers.push(("user-agent".to_string(), profile.user_agent.clone()));
435    headers.push((
436        "accept".to_string(),
437        "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string(),
438    ));
439    let site = if referer.is_some() {
440        "same-origin"
441    } else {
442        "none"
443    };
444    headers.push(("sec-fetch-site".to_string(), site.to_string()));
445    headers.push(("sec-fetch-mode".to_string(), "navigate".to_string()));
446    headers.push((
447        "accept-language".to_string(),
448        build_safari_accept_language(&profile.languages),
449    ));
450    headers.push(("priority".to_string(), "u=0, i".to_string()));
451    headers.push((
452        "accept-encoding".to_string(),
453        "gzip, deflate, br".to_string(),
454    ));
455    if let Some(r) = referer {
456        headers.push(("referer".to_string(), r.to_string()));
457    }
458
459    headers
460}
461
462// ============================================================================
463// Accept-Language builders
464// ============================================================================
465
466fn build_accept_language(languages: &[String]) -> String {
467    if languages.is_empty() {
468        return "en-US,en;q=0.9".to_string();
469    }
470    let mut parts = Vec::with_capacity(languages.len());
471    for (i, lang) in languages.iter().enumerate() {
472        if i == 0 {
473            parts.push(lang.clone());
474        } else {
475            let q = 1.0 - (i as f64 * 0.1);
476            if q > 0.0 {
477                parts.push(format!("{};q={:.1}", lang, q));
478            }
479        }
480    }
481    parts.join(",")
482}
483
484fn build_firefox_accept_language(languages: &[String]) -> String {
485    if languages.is_empty() {
486        return "en-US,en;q=0.5".to_string();
487    }
488    let mut parts = Vec::with_capacity(languages.len());
489    for (i, lang) in languages.iter().enumerate() {
490        if i == 0 {
491            parts.push(lang.clone());
492        } else {
493            let q = 0.5 - ((i - 1) as f64 * 0.2);
494            if q > 0.0 {
495                parts.push(format!("{};q={:.1}", lang, q));
496            }
497        }
498    }
499    parts.join(",")
500}
501
502fn build_safari_accept_language(languages: &[String]) -> String {
503    build_accept_language(languages)
504}
505
506// ============================================================================
507// Client Hints helpers
508// ============================================================================
509
510fn build_sec_ch_ua(profile: &StealthProfile) -> String {
511    let major_version = profile.browser_version.split('.').next().unwrap_or("147");
512    format!(
513        "\"Google Chrome\";v=\"{v}\", \"Not.A/Brand\";v=\"8\", \"Chromium\";v=\"{v}\"",
514        v = major_version
515    )
516}
517
518fn build_sec_ch_ua_full_version_list(profile: &StealthProfile) -> String {
519    let v = &profile.browser_version;
520    format!("\"Google Chrome\";v=\"{v}\", \"Not.A/Brand\";v=\"8.0.0.0\", \"Chromium\";v=\"{v}\"")
521}
522
523fn chrome_platform_version(os_name: &str, os_version: &str) -> String {
524    let parts: Vec<&str> = os_version.split('.').collect();
525    if parts.len() >= 3 {
526        return os_version.to_string();
527    }
528    match os_name {
529        "Windows" | "macOS" => match parts.len() {
530            1 => format!("{}.0.0", parts[0]),
531            2 => format!("{}.{}.0", parts[0], parts[1]),
532            _ => os_version.to_string(),
533        },
534        "Linux" => String::new(),
535        _ => os_version.to_string(),
536    }
537}
538
539fn quantize_device_memory(gb: f64) -> f64 {
540    const SPEC: [f64; 6] = [0.25, 0.5, 1.0, 2.0, 4.0, 8.0];
541    if gb < SPEC[0] {
542        return SPEC[0];
543    }
544    let mut out = SPEC[0];
545    for &v in &SPEC {
546        if v <= gb {
547            out = v;
548        }
549    }
550    out
551}
552
553// ============================================================================
554// Helpers
555// ============================================================================
556
557fn compute_sec_fetch_site(target_url: &str, origin: &str) -> &'static str {
558    let t = url::Url::parse(target_url).ok();
559    let o = url::Url::parse(origin).ok();
560    match (t, o) {
561        (Some(tu), Some(ou)) => {
562            if tu.host_str() == ou.host_str() {
563                "same-origin"
564            } else if same_site(&tu, &ou) {
565                "same-site"
566            } else {
567                "cross-site"
568            }
569        }
570        _ => "cross-site",
571    }
572}
573
574fn same_site(a: &url::Url, b: &url::Url) -> bool {
575    fn tail2(u: &url::Url) -> Option<String> {
576        let host = u.host_str()?;
577        let mut parts: Vec<&str> = host.rsplit('.').collect();
578        if parts.len() < 2 {
579            return Some(host.to_string());
580        }
581        parts.truncate(2);
582        parts.reverse();
583        Some(parts.join("."))
584    }
585    tail2(a) == tail2(b)
586}
587
588// ============================================================================
589// Tests
590// ============================================================================
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::stealth::presets::*;
596
597    #[test]
598    fn accept_language_single() {
599        assert_eq!(build_accept_language(&["en-US".into()]), "en-US");
600    }
601
602    #[test]
603    fn accept_language_multiple() {
604        assert_eq!(
605            build_accept_language(&["en-US".into(), "en".into()]),
606            "en-US,en;q=0.9"
607        );
608    }
609
610    #[test]
611    fn accept_language_empty() {
612        assert_eq!(build_accept_language(&[]), "en-US,en;q=0.9");
613    }
614
615    #[test]
616    fn firefox_accept_language_uses_q_05() {
617        assert_eq!(
618            build_firefox_accept_language(&["en-US".into(), "en".into()]),
619            "en-US,en;q=0.5"
620        );
621    }
622
623    #[test]
624    fn chrome_headers_first_visit_count() {
625        let profile = chrome_147_windows();
626        let headers = chrome_headers(&profile);
627        assert_eq!(headers.len(), 13);
628        let names: Vec<&str> = headers.iter().map(|(k, _)| k.as_str()).collect();
629        for required in &["sec-ch-ua", "sec-ch-ua-mobile", "sec-ch-ua-platform"] {
630            assert!(names.contains(required), "missing {required}");
631        }
632        for forbidden in &[
633            "sec-ch-ua-arch",
634            "sec-ch-ua-bitness",
635            "sec-ch-ua-full-version-list",
636            "sec-ch-ua-model",
637            "sec-ch-ua-platform-version",
638            "sec-ch-ua-wow64",
639        ] {
640            assert!(
641                !names.contains(forbidden),
642                "{forbidden} leaked on first visit"
643            );
644        }
645    }
646
647    #[test]
648    fn chrome_headers_accept_ch_includes_high_entropy() {
649        let profile = chrome_147_windows();
650        let headers = chrome_headers_with_accept_ch(&profile);
651        let names: Vec<&str> = headers.iter().map(|(k, _)| k.as_str()).collect();
652        for required in &[
653            "sec-ch-ua-arch",
654            "sec-ch-ua-bitness",
655            "sec-ch-ua-full-version-list",
656            "sec-ch-ua-model",
657            "sec-ch-ua-platform-version",
658            "sec-ch-ua-wow64",
659        ] {
660            assert!(names.contains(required), "missing {required}");
661        }
662    }
663
664    #[test]
665    fn firefox_headers_have_no_sec_ch_ua() {
666        let profile = firefox_135_macos();
667        let headers = firefox_headers(&profile);
668        for (k, _) in &headers {
669            assert!(!k.starts_with("sec-ch-ua"), "Firefox must not send {k}");
670        }
671        assert!(!headers.iter().any(|(k, _)| k == "priority"));
672    }
673
674    #[test]
675    fn firefox_headers_have_correct_accept() {
676        let profile = firefox_135_macos();
677        let headers = firefox_headers(&profile);
678        let accept = headers.iter().find(|(k, _)| k == "accept").unwrap();
679        assert_eq!(
680            accept.1,
681            "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
682        );
683    }
684
685    #[test]
686    fn platform_version_triple_padded() {
687        assert_eq!(chrome_platform_version("Windows", "10.0"), "10.0.0");
688        assert_eq!(chrome_platform_version("Windows", "11"), "11.0.0");
689        assert_eq!(chrome_platform_version("macOS", "15.2"), "15.2.0");
690        assert_eq!(chrome_platform_version("Linux", "anything"), "");
691    }
692
693    #[test]
694    fn device_memory_quantizes_to_w3_spec_set() {
695        assert_eq!(quantize_device_memory(8.0), 8.0);
696        assert_eq!(quantize_device_memory(4.0), 4.0);
697        assert_eq!(quantize_device_memory(16.0), 8.0);
698        assert_eq!(quantize_device_memory(6.0), 4.0);
699        assert_eq!(quantize_device_memory(0.3), 0.25);
700        assert_eq!(quantize_device_memory(0.0), 0.25);
701    }
702
703    #[test]
704    fn region_languages_amazon_fr() {
705        let langs = region_languages_for_url("https://www.amazon.fr/").unwrap();
706        assert_eq!(langs, vec!["fr-FR", "fr", "en-US", "en"]);
707    }
708
709    #[test]
710    fn region_languages_amazon_jp() {
711        let langs = region_languages_for_url("https://www.amazon.co.jp/").unwrap();
712        assert_eq!(langs, vec!["ja-JP", "ja", "en-US", "en"]);
713    }
714
715    #[test]
716    fn region_languages_amazon_com_no_override() {
717        assert!(region_languages_for_url("https://www.amazon.com/").is_none());
718    }
719
720    #[test]
721    fn region_languages_amazon_co_uk_no_override() {
722        assert!(region_languages_for_url("https://www.amazon.co.uk/").is_none());
723    }
724
725    #[test]
726    fn nav_headers_for_url_overrides_amazon_fr() {
727        let profile = chrome_147_macos();
728        let hdrs = nav_headers_for_url(&profile, "https://www.amazon.fr/", false);
729        let al = hdrs
730            .iter()
731            .find(|(k, _)| k.eq_ignore_ascii_case("accept-language"))
732            .unwrap();
733        assert_eq!(al.1, "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7");
734    }
735
736    #[test]
737    fn nav_headers_for_url_overrides_amazon_de_firefox() {
738        let profile = firefox_135_macos();
739        let hdrs = nav_headers_for_url(&profile, "https://www.amazon.de/", false);
740        let al = hdrs
741            .iter()
742            .find(|(k, _)| k.eq_ignore_ascii_case("accept-language"))
743            .unwrap();
744        assert_eq!(al.1, "de-DE,de;q=0.5,en-US;q=0.3,en;q=0.1");
745    }
746
747    #[test]
748    fn pixel_mobile_emits_mobile_client_hints() {
749        let profile = pixel_9_pro_chrome_148();
750        let headers = chrome_headers_with_accept_ch(&profile);
751        let h: std::collections::HashMap<_, _> = headers.iter().cloned().collect();
752        assert_eq!(h.get("sec-ch-ua-mobile").map(String::as_str), Some("?1"));
753        assert_eq!(
754            h.get("sec-ch-ua-platform").map(String::as_str),
755            Some("\"Android\"")
756        );
757        assert_eq!(
758            h.get("sec-ch-ua-model").map(String::as_str),
759            Some("\"Pixel 9 Pro\"")
760        );
761        assert_eq!(
762            h.get("sec-ch-ua-form-factors").map(String::as_str),
763            Some("\"Mobile\"")
764        );
765    }
766
767    #[test]
768    fn desktop_emits_desktop_client_hints() {
769        let profile = chrome_147_macos();
770        let headers = chrome_headers_with_accept_ch(&profile);
771        let h: std::collections::HashMap<_, _> = headers.iter().cloned().collect();
772        assert_eq!(h.get("sec-ch-ua-mobile").map(String::as_str), Some("?0"));
773        assert_eq!(
774            h.get("sec-ch-ua-form-factors").map(String::as_str),
775            Some("\"Desktop\"")
776        );
777    }
778
779    #[test]
780    fn safari_headers_have_no_sec_ch_ua() {
781        let profile = safari_ios_18();
782        let headers = safari_headers(&profile);
783        for (k, _) in &headers {
784            assert!(!k.starts_with("sec-ch-ua"), "Safari must not send {k}");
785        }
786    }
787
788    #[test]
789    fn fetch_headers_mobile_flag_matches_nav() {
790        let pixel = pixel_9_pro_chrome_148();
791        let fh: std::collections::HashMap<_, _> = chrome_headers_fetch(
792            &pixel,
793            "https://example.com/x.js",
794            Some("https://example.com"),
795        )
796        .into_iter()
797        .collect();
798        assert_eq!(fh.get("sec-ch-ua-mobile").map(String::as_str), Some("?1"));
799    }
800}