1use crate::stealth::{DeviceClass, StealthProfile};
7
8pub 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
18pub 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
29pub 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
47pub 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
92pub 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
105pub 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
121pub 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 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 headers.push((
222 "sec-ch-ua-mobile".to_string(),
223 if is_mobile { "?1" } else { "?0" }.to_string(),
224 ));
225 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 headers.push(("upgrade-insecure-requests".to_string(), "1".to_string()));
283 headers.push(("user-agent".to_string(), profile.user_agent.clone()));
285 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 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 headers.push((
296 "accept-encoding".to_string(),
297 "gzip, deflate, br, zstd".to_string(),
298 ));
299 headers.push((
301 "accept-language".to_string(),
302 build_accept_language(&profile.languages),
303 ));
304 headers.push(("priority".to_string(), "u=0, i".to_string()));
306
307 headers
308}
309
310pub 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
391pub 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
462fn 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
506fn 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
553fn 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#[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}