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