Skip to main content

browser_commander/fingerprint/
profile.rs

1//! Normalization and validation of fingerprint profiles.
2//!
3//! A fingerprint profile is the complete description of the environment a page
4//! is allowed to see: who the browser claims to be, where it claims to run, and
5//! what hardware it claims to have.
6//!
7//! Every field here is applied through a documented mechanism -- a Chrome
8//! switch, a CDP `Emulation` command, or a page init script -- and the
9//! mechanism is recorded in
10//! [`FINGERPRINT_FIELD_MECHANISMS`](crate::fingerprint::FINGERPRINT_FIELD_MECHANISMS)
11//! so callers can
12//! tell an override the browser enforces from an override that is only a
13//! JavaScript patch. See `docs/case-studies/issue-79` for the surfaces that
14//! have no mechanism at all.
15//!
16//! The structs serialize to the camelCase field names the Chrome DevTools
17//! Protocol uses, so a profile can go straight into a CDP payload without a
18//! second vocabulary in between. This is the Rust side of
19//! `js/src/fingerprint/profile.js` and
20//! `python/src/browser_commander/fingerprint/profile.py`; the unit tests are
21//! translations of each other.
22
23use anyhow::{bail, Result};
24use serde::{Deserialize, Serialize};
25
26use super::derive::derive_user_agent_data;
27
28/// One entry of a User-Agent Client Hints brand list.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct BrandVersion {
32    /// Brand name, for example `Google Chrome`.
33    pub brand: String,
34    /// Version string, major only in `brands` and full in `fullVersionList`.
35    pub version: String,
36}
37
38/// The User-Agent Client Hints a page can read through `navigator.userAgentData`.
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
41pub struct UserAgentData {
42    /// Low-entropy brand list, sent on every request.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub brands: Option<Vec<BrandVersion>>,
45    /// High-entropy brand list with full version numbers.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub full_version_list: Option<Vec<BrandVersion>>,
48    /// Platform hint, for example `Windows`.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub platform: Option<String>,
51    /// Platform version hint; on Windows this is the only real version signal.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub platform_version: Option<String>,
54    /// CPU architecture hint, for example `x86`.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub architecture: Option<String>,
57    /// CPU bitness hint, for example `64`.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub bitness: Option<String>,
60    /// Deprecated in the protocol but still the only way to control the
61    /// `uaFullVersion` high-entropy hint: with `fullVersionList` alone the page
62    /// still reads the real Chrome build number.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub full_version: Option<String>,
65    /// Device model, non-empty only on mobile.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub model: Option<String>,
68    /// Whether the browser reports itself as mobile.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub mobile: Option<bool>,
71    /// Whether a 32-bit browser is running on 64-bit Windows.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub wow64: Option<bool>,
74    /// Form factor hints, for example `["Desktop"]`.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub form_factors: Option<Vec<String>>,
77}
78
79/// The screen a page believes the browser window lives on.
80#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
82pub struct ScreenProfile {
83    /// `screen.width` in CSS pixels.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub width: Option<u32>,
86    /// `screen.height` in CSS pixels.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub height: Option<u32>,
89    /// `screen.availWidth`, the width left after system chrome.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub avail_width: Option<u32>,
92    /// `screen.availHeight`, the height left after system chrome.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub avail_height: Option<u32>,
95    /// `screen.colorDepth` in bits.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub color_depth: Option<u32>,
98    /// `screen.pixelDepth` in bits.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub pixel_depth: Option<u32>,
101}
102
103/// The viewport the renderer lays the page out in.
104#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
106pub struct ViewportProfile {
107    /// Layout width in CSS pixels.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub width: Option<u32>,
110    /// Layout height in CSS pixels.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub height: Option<u32>,
113    /// Device pixel ratio.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub device_scale_factor: Option<f64>,
116    /// Whether the renderer emulates a mobile device.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub mobile: Option<bool>,
119}
120
121/// The position the geolocation API reports.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase", deny_unknown_fields)]
124pub struct GeolocationProfile {
125    /// Latitude in degrees, between -90 and 90.
126    pub latitude: f64,
127    /// Longitude in degrees, between -180 and 180.
128    pub longitude: f64,
129    /// Accuracy radius in metres.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub accuracy: Option<f64>,
132}
133
134/// The strings the WebGL debug renderer extension reports.
135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
137pub struct WebglProfile {
138    /// `VENDOR`, which real Chrome always reports as `WebKit`.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub vendor: Option<String>,
141    /// `RENDERER`, which real Chrome always reports as `WebKit WebGL`.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub renderer: Option<String>,
144    /// `UNMASKED_VENDOR_WEBGL`, the real GPU vendor.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub unmasked_vendor: Option<String>,
147    /// `UNMASKED_RENDERER_WEBGL`, the real GPU and driver.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub unmasked_renderer: Option<String>,
150}
151
152/// The `prefers-color-scheme` media feature.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "kebab-case")]
155pub enum ColorScheme {
156    /// `prefers-color-scheme: light`.
157    Light,
158    /// `prefers-color-scheme: dark`.
159    Dark,
160    /// No preference expressed.
161    NoPreference,
162}
163
164/// The `prefers-reduced-motion` media feature.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "kebab-case")]
167pub enum ReducedMotion {
168    /// `prefers-reduced-motion: reduce`.
169    Reduce,
170    /// No preference expressed.
171    NoPreference,
172}
173
174/// The `forced-colors` media feature.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "kebab-case")]
177pub enum ForcedColors {
178    /// `forced-colors: active`.
179    Active,
180    /// `forced-colors: none`.
181    None,
182}
183
184/// The complete description of the environment a page is allowed to see.
185///
186/// Every field is optional and an unset field is left alone: the profile
187/// describes what to override, never what to default to.
188#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
190pub struct FingerprintProfile {
191    /// The full User-Agent string.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub user_agent: Option<String>,
194    /// Client hints; derived from `user_agent` when left unset.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub user_agent_data: Option<UserAgentData>,
197    /// The `Accept-Language` list; derived from `languages` when left unset.
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub accept_language: Option<String>,
200    /// `navigator.languages`.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub languages: Option<Vec<String>>,
203    /// The ICU locale used for formatting.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub locale: Option<String>,
206    /// IANA time zone identifier, for example `Europe/Berlin`.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub timezone_id: Option<String>,
209    /// `navigator.platform`.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub platform: Option<String>,
212    /// `navigator.vendor`.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub vendor: Option<String>,
215    /// `navigator.hardwareConcurrency`, the reported core count.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub hardware_concurrency: Option<u32>,
218    /// `navigator.deviceMemory` in gigabytes.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub device_memory: Option<f64>,
221    /// `navigator.maxTouchPoints`.
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub max_touch_points: Option<u32>,
224    /// `navigator.doNotTrack`.
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub do_not_track: Option<String>,
227    /// The screen the page believes it is on.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub screen: Option<ScreenProfile>,
230    /// The viewport the renderer lays the page out in.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub viewport: Option<ViewportProfile>,
233    /// The strings the WebGL debug renderer extension reports.
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub webgl: Option<WebglProfile>,
236    /// The position the geolocation API reports.
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub geolocation: Option<GeolocationProfile>,
239    /// The `prefers-color-scheme` media feature.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub color_scheme: Option<ColorScheme>,
242    /// The `prefers-reduced-motion` media feature.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub reduced_motion: Option<ReducedMotion>,
245    /// The `forced-colors` media feature.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub forced_colors: Option<ForcedColors>,
248}
249
250fn positive_integer(value: Option<u32>, name: &str) -> Result<Option<u32>> {
251    if value == Some(0) {
252        bail!("{name} must be a positive integer");
253    }
254    Ok(value)
255}
256
257fn positive_number(value: Option<f64>, name: &str) -> Result<Option<f64>> {
258    if let Some(number) = value {
259        if !number.is_finite() || number <= 0.0 {
260            bail!("{name} must be a positive number");
261        }
262    }
263    Ok(value)
264}
265
266fn validate_screen(screen: &ScreenProfile) -> Result<()> {
267    positive_integer(screen.width, "screen.width")?;
268    positive_integer(screen.height, "screen.height")?;
269    positive_integer(screen.avail_width, "screen.availWidth")?;
270    positive_integer(screen.avail_height, "screen.availHeight")?;
271    positive_integer(screen.color_depth, "screen.colorDepth")?;
272    positive_integer(screen.pixel_depth, "screen.pixelDepth")?;
273    if screen.width.is_none() != screen.height.is_none() {
274        bail!("screen.width and screen.height must be provided together");
275    }
276    Ok(())
277}
278
279fn validate_viewport(viewport: &ViewportProfile) -> Result<()> {
280    positive_integer(viewport.width, "viewport.width")?;
281    positive_integer(viewport.height, "viewport.height")?;
282    positive_number(viewport.device_scale_factor, "viewport.deviceScaleFactor")?;
283    if viewport.width.is_none() != viewport.height.is_none() {
284        bail!("viewport.width and viewport.height must be provided together");
285    }
286    Ok(())
287}
288
289fn validate_geolocation(geolocation: &GeolocationProfile) -> Result<()> {
290    for (value, name) in [
291        (geolocation.latitude, "geolocation.latitude"),
292        (geolocation.longitude, "geolocation.longitude"),
293    ] {
294        if !value.is_finite() {
295            bail!("{name} must be a finite number");
296        }
297    }
298    if !(-90.0..=90.0).contains(&geolocation.latitude) {
299        bail!("geolocation.latitude must be between -90 and 90");
300    }
301    if !(-180.0..=180.0).contains(&geolocation.longitude) {
302        bail!("geolocation.longitude must be between -180 and 180");
303    }
304    positive_number(geolocation.accuracy, "geolocation.accuracy")?;
305    Ok(())
306}
307
308/// Reject the q-value form Chrome misparses.
309///
310/// Chrome derives both the `Accept-Language` header and `navigator.languages`
311/// from this one string, and it splits on commas without stripping q-values.
312/// Passing `de-DE,de;q=0.9` therefore yields the language tag `"de;q=0.9"` and
313/// the header `de-DE,de;q=0.9;q=0.9`; passing the plain list `de-DE,de,en`
314/// yields correct tags and the header `de-DE,de;q=0.9,en;q=0.8` that a real
315/// browser sends. Measured in
316/// `docs/case-studies/issue-79/analysis-artifacts/ua-hints-detail.json`.
317fn validate_accept_language(accept_language: &str) -> Result<()> {
318    if accept_language.contains(';') {
319        bail!(
320            "acceptLanguage must be a plain comma-separated language list without \
321             q-values; Chrome generates the quality values itself"
322        );
323    }
324    Ok(())
325}
326
327/// Keep `uaFullVersion` consistent with `fullVersionList`.
328fn with_full_version(mut data: UserAgentData) -> UserAgentData {
329    if data.full_version.is_some() {
330        return data;
331    }
332    let primary = data.full_version_list.as_ref().and_then(|list| {
333        list.iter()
334            .find(|entry| entry.brand == "Google Chrome" || entry.brand == "Chromium")
335            .map(|entry| entry.version.clone())
336    });
337    if let Some(version) = primary {
338        data.full_version = Some(version);
339    }
340    data
341}
342
343/// Normalize and validate a fingerprint profile.
344///
345/// Unknown keys are rejected when a profile is deserialized rather than
346/// ignored: a typo in `hardwareConcurency` would otherwise silently leave the
347/// real core count exposed, which is exactly the failure this module exists to
348/// prevent.
349pub fn resolve_fingerprint_profile(profile: &FingerprintProfile) -> Result<FingerprintProfile> {
350    let mut resolved = profile.clone();
351
352    if let Some(languages) = &resolved.languages {
353        if languages.is_empty() {
354            bail!("languages must not be empty");
355        }
356        if resolved.accept_language.is_none() {
357            resolved.accept_language = Some(languages.join(","));
358        }
359    }
360    if let Some(accept_language) = &resolved.accept_language {
361        validate_accept_language(accept_language)?;
362    }
363    if resolved.user_agent_data.is_none() {
364        if let Some(user_agent) = &resolved.user_agent {
365            resolved.user_agent_data = derive_user_agent_data(user_agent);
366        }
367    }
368    resolved.user_agent_data = resolved.user_agent_data.map(with_full_version);
369
370    positive_integer(resolved.hardware_concurrency, "hardwareConcurrency")?;
371    positive_number(resolved.device_memory, "deviceMemory")?;
372    if let Some(screen) = &resolved.screen {
373        validate_screen(screen)?;
374    }
375    if let Some(viewport) = &resolved.viewport {
376        validate_viewport(viewport)?;
377    }
378    if let Some(geolocation) = &resolved.geolocation {
379        validate_geolocation(geolocation)?;
380    }
381    Ok(resolved)
382}
383
384impl FingerprintProfile {
385    /// Normalize and validate this profile, returning the resolved copy.
386    pub fn resolve(&self) -> Result<FingerprintProfile> {
387        resolve_fingerprint_profile(self)
388    }
389
390    /// The camelCase names of the fields this profile actually sets.
391    ///
392    /// Every name here has an entry in
393    /// [`FINGERPRINT_FIELD_MECHANISMS`](crate::fingerprint::FINGERPRINT_FIELD_MECHANISMS), which
394    /// is what lets a caller report how strong each override is.
395    pub fn populated_fields(&self) -> Vec<&'static str> {
396        let present: [(&'static str, bool); 19] = [
397            ("userAgent", self.user_agent.is_some()),
398            ("userAgentData", self.user_agent_data.is_some()),
399            ("acceptLanguage", self.accept_language.is_some()),
400            ("languages", self.languages.is_some()),
401            ("locale", self.locale.is_some()),
402            ("timezoneId", self.timezone_id.is_some()),
403            ("platform", self.platform.is_some()),
404            ("vendor", self.vendor.is_some()),
405            ("hardwareConcurrency", self.hardware_concurrency.is_some()),
406            ("deviceMemory", self.device_memory.is_some()),
407            ("maxTouchPoints", self.max_touch_points.is_some()),
408            ("doNotTrack", self.do_not_track.is_some()),
409            ("screen", self.screen.is_some()),
410            ("viewport", self.viewport.is_some()),
411            ("webgl", self.webgl.is_some()),
412            ("geolocation", self.geolocation.is_some()),
413            ("colorScheme", self.color_scheme.is_some()),
414            ("reducedMotion", self.reduced_motion.is_some()),
415            ("forcedColors", self.forced_colors.is_some()),
416        ];
417        present
418            .into_iter()
419            .filter_map(|(name, set)| set.then_some(name))
420            .collect()
421    }
422
423    /// Set the User-Agent string.
424    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
425        self.user_agent = Some(user_agent.into());
426        self
427    }
428
429    /// Set the client hints explicitly instead of deriving them.
430    pub fn user_agent_data(mut self, user_agent_data: UserAgentData) -> Self {
431        self.user_agent_data = Some(user_agent_data);
432        self
433    }
434
435    /// Set the `Accept-Language` list.
436    pub fn accept_language(mut self, accept_language: impl Into<String>) -> Self {
437        self.accept_language = Some(accept_language.into());
438        self
439    }
440
441    /// Set `navigator.languages`.
442    pub fn languages<I, S>(mut self, languages: I) -> Self
443    where
444        I: IntoIterator<Item = S>,
445        S: Into<String>,
446    {
447        self.languages = Some(languages.into_iter().map(Into::into).collect());
448        self
449    }
450
451    /// Set the formatting locale.
452    pub fn locale(mut self, locale: impl Into<String>) -> Self {
453        self.locale = Some(locale.into());
454        self
455    }
456
457    /// Set the IANA time zone identifier.
458    pub fn timezone_id(mut self, timezone_id: impl Into<String>) -> Self {
459        self.timezone_id = Some(timezone_id.into());
460        self
461    }
462
463    /// Set `navigator.platform`.
464    pub fn platform(mut self, platform: impl Into<String>) -> Self {
465        self.platform = Some(platform.into());
466        self
467    }
468
469    /// Set `navigator.vendor`.
470    pub fn vendor(mut self, vendor: impl Into<String>) -> Self {
471        self.vendor = Some(vendor.into());
472        self
473    }
474
475    /// Set the reported core count.
476    pub fn hardware_concurrency(mut self, cores: u32) -> Self {
477        self.hardware_concurrency = Some(cores);
478        self
479    }
480
481    /// Set the reported device memory in gigabytes.
482    pub fn device_memory(mut self, gigabytes: f64) -> Self {
483        self.device_memory = Some(gigabytes);
484        self
485    }
486
487    /// Set `navigator.maxTouchPoints`.
488    pub fn max_touch_points(mut self, points: u32) -> Self {
489        self.max_touch_points = Some(points);
490        self
491    }
492
493    /// Set `navigator.doNotTrack`.
494    pub fn do_not_track(mut self, do_not_track: impl Into<String>) -> Self {
495        self.do_not_track = Some(do_not_track.into());
496        self
497    }
498
499    /// Set the screen the page believes it is on.
500    pub fn screen(mut self, screen: ScreenProfile) -> Self {
501        self.screen = Some(screen);
502        self
503    }
504
505    /// Set the viewport the renderer lays the page out in.
506    pub fn viewport(mut self, viewport: ViewportProfile) -> Self {
507        self.viewport = Some(viewport);
508        self
509    }
510
511    /// Set the WebGL vendor and renderer strings.
512    pub fn webgl(mut self, webgl: WebglProfile) -> Self {
513        self.webgl = Some(webgl);
514        self
515    }
516
517    /// Set the position the geolocation API reports.
518    pub fn geolocation(mut self, geolocation: GeolocationProfile) -> Self {
519        self.geolocation = Some(geolocation);
520        self
521    }
522
523    /// Set the `prefers-color-scheme` media feature.
524    pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
525        self.color_scheme = Some(color_scheme);
526        self
527    }
528
529    /// Set the `prefers-reduced-motion` media feature.
530    pub fn reduced_motion(mut self, reduced_motion: ReducedMotion) -> Self {
531        self.reduced_motion = Some(reduced_motion);
532        self
533    }
534
535    /// Set the `forced-colors` media feature.
536    pub fn forced_colors(mut self, forced_colors: ForcedColors) -> Self {
537        self.forced_colors = Some(forced_colors);
538        self
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    const CHROME_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
547         (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
548
549    fn brand(name: &str, version: &str) -> BrandVersion {
550        BrandVersion {
551            brand: name.to_string(),
552            version: version.to_string(),
553        }
554    }
555
556    #[test]
557    fn an_empty_profile_serializes_to_an_empty_object() {
558        let profile = FingerprintProfile::default()
559            .resolve()
560            .expect("empty profile resolves");
561
562        assert_eq!(
563            serde_json::to_value(&profile).expect("serializes"),
564            serde_json::json!({})
565        );
566        assert!(profile.populated_fields().is_empty());
567    }
568
569    #[test]
570    fn drops_fields_that_were_not_supplied_instead_of_filling_in_defaults() {
571        let profile = FingerprintProfile::default()
572            .locale("de-DE")
573            .resolve()
574            .expect("resolves");
575
576        assert_eq!(profile.populated_fields(), vec!["locale"]);
577    }
578
579    #[test]
580    fn rejects_an_unknown_field_rather_than_silently_ignoring_it() {
581        let error = serde_json::from_str::<FingerprintProfile>(r#"{"hardwareConcurency": 8}"#)
582            .expect_err("unknown field is rejected");
583
584        assert!(error.to_string().contains("hardwareConcurency"), "{error}");
585    }
586
587    #[test]
588    fn derives_accept_language_from_languages() {
589        let profile = FingerprintProfile::default()
590            .languages(["de-DE", "de", "en"])
591            .resolve()
592            .expect("resolves");
593
594        assert_eq!(profile.accept_language.as_deref(), Some("de-DE,de,en"));
595        assert_eq!(
596            profile.languages,
597            Some(vec![
598                "de-DE".to_string(),
599                "de".to_string(),
600                "en".to_string()
601            ])
602        );
603    }
604
605    #[test]
606    fn keeps_an_explicit_accept_language_over_the_derived_one() {
607        let profile = FingerprintProfile::default()
608            .languages(["de-DE", "de"])
609            .accept_language("fr-FR,fr")
610            .resolve()
611            .expect("resolves");
612
613        assert_eq!(profile.accept_language.as_deref(), Some("fr-FR,fr"));
614    }
615
616    // Chrome splits acceptLanguage on commas without stripping q-values, so a
617    // q-value ends up inside a language tag and doubled in the header.
618    #[test]
619    fn rejects_q_values_in_accept_language_which_chrome_would_misparse() {
620        let error = FingerprintProfile::default()
621            .accept_language("de-DE,de;q=0.9")
622            .resolve()
623            .expect_err("q-values are rejected");
624
625        assert!(error.to_string().contains("without q-values"), "{error}");
626    }
627
628    #[test]
629    fn derives_client_hints_from_a_chrome_user_agent() {
630        let profile = FingerprintProfile::default()
631            .user_agent(CHROME_UA)
632            .resolve()
633            .expect("resolves");
634
635        let hints = profile.user_agent_data.expect("derived hints");
636        assert_eq!(hints.platform.as_deref(), Some("Windows"));
637        assert_eq!(hints.architecture.as_deref(), Some("x86"));
638        assert_eq!(hints.bitness.as_deref(), Some("64"));
639        assert_eq!(hints.mobile, Some(false));
640        assert!(hints
641            .brands
642            .expect("brands")
643            .iter()
644            .any(|entry| entry.brand == "Google Chrome" && entry.version == "140"));
645    }
646
647    #[test]
648    fn fills_ua_full_version_from_the_chrome_entry_of_full_version_list() {
649        let profile = FingerprintProfile::default()
650            .user_agent_data(UserAgentData {
651                full_version_list: Some(vec![
652                    brand("Not=A?Brand", "24.0.0.0"),
653                    brand("Google Chrome", "140.0.7000.1"),
654                ]),
655                ..UserAgentData::default()
656            })
657            .resolve()
658            .expect("resolves");
659
660        assert_eq!(
661            profile
662                .user_agent_data
663                .expect("hints")
664                .full_version
665                .as_deref(),
666            Some("140.0.7000.1")
667        );
668    }
669
670    #[test]
671    fn keeps_an_explicit_full_version_instead_of_deriving_one() {
672        let profile = FingerprintProfile::default()
673            .user_agent_data(UserAgentData {
674                full_version: Some("99.1.2.3".to_string()),
675                full_version_list: Some(vec![brand("Google Chrome", "140.0.0.0")]),
676                ..UserAgentData::default()
677            })
678            .resolve()
679            .expect("resolves");
680
681        assert_eq!(
682            profile
683                .user_agent_data
684                .expect("hints")
685                .full_version
686                .as_deref(),
687            Some("99.1.2.3")
688        );
689    }
690
691    #[test]
692    fn lets_an_explicit_user_agent_data_win_over_the_derived_one() {
693        let profile = FingerprintProfile::default()
694            .user_agent(CHROME_UA)
695            .user_agent_data(UserAgentData {
696                platform: Some("macOS".to_string()),
697                ..UserAgentData::default()
698            })
699            .resolve()
700            .expect("resolves");
701
702        assert_eq!(
703            profile.user_agent_data.expect("hints").platform.as_deref(),
704            Some("macOS")
705        );
706    }
707
708    #[test]
709    fn accepts_every_configurable_field_at_once() {
710        let profile = FingerprintProfile::default()
711            .user_agent(CHROME_UA)
712            .languages(["de-DE", "de"])
713            .locale("de-DE")
714            .timezone_id("Europe/Berlin")
715            .platform("Win32")
716            .vendor("Google Inc.")
717            .hardware_concurrency(24)
718            .device_memory(32.0)
719            .max_touch_points(5)
720            .do_not_track("1")
721            .screen(ScreenProfile {
722                width: Some(3840),
723                height: Some(2160),
724                avail_width: Some(3840),
725                avail_height: Some(2100),
726                color_depth: Some(30),
727                pixel_depth: Some(30),
728            })
729            .viewport(ViewportProfile {
730                width: Some(1600),
731                height: Some(900),
732                device_scale_factor: Some(2.0),
733                mobile: Some(false),
734            })
735            .webgl(WebglProfile {
736                unmasked_vendor: Some("NVIDIA".to_string()),
737                unmasked_renderer: Some("RTX 4090".to_string()),
738                ..WebglProfile::default()
739            })
740            .geolocation(GeolocationProfile {
741                latitude: 52.52,
742                longitude: 13.405,
743                accuracy: Some(12.0),
744            })
745            .color_scheme(ColorScheme::Dark)
746            .reduced_motion(ReducedMotion::Reduce)
747            .forced_colors(ForcedColors::Active)
748            .resolve()
749            .expect("resolves");
750
751        assert_eq!(profile.hardware_concurrency, Some(24));
752        assert_eq!(profile.device_memory, Some(32.0));
753        assert_eq!(profile.screen.expect("screen").color_depth, Some(30));
754        assert_eq!(
755            profile.viewport.expect("viewport").device_scale_factor,
756            Some(2.0)
757        );
758        assert_eq!(
759            profile.webgl.expect("webgl").unmasked_renderer.as_deref(),
760            Some("RTX 4090")
761        );
762        assert_eq!(
763            profile.geolocation.expect("geolocation").accuracy,
764            Some(12.0)
765        );
766        assert_eq!(profile.forced_colors, Some(ForcedColors::Active));
767    }
768
769    // The JavaScript port rejects a non-integer or negative core count at run
770    // time; here the type does it, so only zero can reach validation.
771    #[test]
772    fn rejects_a_hardware_concurrency_of_zero() {
773        let error = FingerprintProfile::default()
774            .hardware_concurrency(0)
775            .resolve()
776            .expect_err("zero cores are rejected");
777
778        assert!(
779            error
780                .to_string()
781                .contains("hardwareConcurrency must be a positive integer"),
782            "{error}"
783        );
784    }
785
786    #[test]
787    fn allows_max_touch_points_to_be_zero() {
788        let profile = FingerprintProfile::default()
789            .max_touch_points(0)
790            .resolve()
791            .expect("resolves");
792
793        assert_eq!(profile.max_touch_points, Some(0));
794    }
795
796    #[test]
797    fn rejects_a_device_memory_that_is_not_a_positive_number() {
798        for value in [0.0, -4.0, f64::NAN, f64::INFINITY] {
799            let error = FingerprintProfile::default()
800                .device_memory(value)
801                .resolve()
802                .expect_err("non-positive device memory is rejected");
803            assert!(
804                error
805                    .to_string()
806                    .contains("deviceMemory must be a positive number"),
807                "{value}: {error}"
808            );
809        }
810    }
811
812    #[test]
813    fn requires_screen_width_and_height_to_be_given_together() {
814        let error = FingerprintProfile::default()
815            .screen(ScreenProfile {
816                width: Some(1920),
817                ..ScreenProfile::default()
818            })
819            .resolve()
820            .expect_err("a half screen is rejected");
821
822        assert!(
823            error
824                .to_string()
825                .contains("screen.width and screen.height must be provided together"),
826            "{error}"
827        );
828    }
829
830    #[test]
831    fn requires_viewport_width_and_height_to_be_given_together() {
832        let error = FingerprintProfile::default()
833            .viewport(ViewportProfile {
834                height: Some(900),
835                ..ViewportProfile::default()
836            })
837            .resolve()
838            .expect_err("a half viewport is rejected");
839
840        assert!(
841            error
842                .to_string()
843                .contains("viewport.width and viewport.height must be provided together"),
844            "{error}"
845        );
846    }
847
848    #[test]
849    fn rejects_out_of_range_coordinates() {
850        let latitude = FingerprintProfile::default()
851            .geolocation(GeolocationProfile {
852                latitude: 91.0,
853                longitude: 0.0,
854                accuracy: None,
855            })
856            .resolve()
857            .expect_err("an impossible latitude is rejected");
858        let longitude = FingerprintProfile::default()
859            .geolocation(GeolocationProfile {
860                latitude: 0.0,
861                longitude: -181.0,
862                accuracy: None,
863            })
864            .resolve()
865            .expect_err("an impossible longitude is rejected");
866
867        assert!(
868            latitude
869                .to_string()
870                .contains("latitude must be between -90 and 90"),
871            "{latitude}"
872        );
873        assert!(
874            longitude
875                .to_string()
876                .contains("longitude must be between -180 and 180"),
877            "{longitude}"
878        );
879    }
880
881    #[test]
882    fn rejects_an_unsupported_enum_value() {
883        let error = serde_json::from_str::<FingerprintProfile>(r#"{"colorScheme": "sepia"}"#)
884            .expect_err("an unknown color scheme is rejected");
885
886        assert!(error.to_string().contains("sepia"), "{error}");
887    }
888
889    #[test]
890    fn rejects_an_empty_languages_list() {
891        let empty: [String; 0] = [];
892        let error = FingerprintProfile::default()
893            .languages(empty)
894            .resolve()
895            .expect_err("an empty language list is rejected");
896
897        assert!(
898            error.to_string().contains("languages must not be empty"),
899            "{error}"
900        );
901    }
902
903    #[test]
904    fn round_trips_through_the_camel_case_names_the_protocol_uses() {
905        let profile = FingerprintProfile::default()
906            .hardware_concurrency(8)
907            .timezone_id("UTC")
908            .color_scheme(ColorScheme::NoPreference)
909            .resolve()
910            .expect("resolves");
911        let json = serde_json::to_value(&profile).expect("serializes");
912
913        assert_eq!(
914            json,
915            serde_json::json!({
916                "timezoneId": "UTC",
917                "hardwareConcurrency": 8,
918                "colorScheme": "no-preference",
919            })
920        );
921        assert_eq!(
922            serde_json::from_value::<FingerprintProfile>(json).expect("deserializes"),
923            profile
924        );
925    }
926}