1use anyhow::{bail, Result};
24use serde::{Deserialize, Serialize};
25
26use super::derive::derive_user_agent_data;
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct BrandVersion {
32 pub brand: String,
34 pub version: String,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
41pub struct UserAgentData {
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub brands: Option<Vec<BrandVersion>>,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub full_version_list: Option<Vec<BrandVersion>>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub platform: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub platform_version: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub architecture: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub bitness: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
64 pub full_version: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub model: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub mobile: Option<bool>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub wow64: Option<bool>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub form_factors: Option<Vec<String>>,
77}
78
79#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
82pub struct ScreenProfile {
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub width: Option<u32>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub height: Option<u32>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub avail_width: Option<u32>,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub avail_height: Option<u32>,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub color_depth: Option<u32>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub pixel_depth: Option<u32>,
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
106pub struct ViewportProfile {
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub width: Option<u32>,
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub height: Option<u32>,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub device_scale_factor: Option<f64>,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub mobile: Option<bool>,
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase", deny_unknown_fields)]
124pub struct GeolocationProfile {
125 pub latitude: f64,
127 pub longitude: f64,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub accuracy: Option<f64>,
132}
133
134#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
137pub struct WebglProfile {
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub vendor: Option<String>,
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub renderer: Option<String>,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub unmasked_vendor: Option<String>,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub unmasked_renderer: Option<String>,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "kebab-case")]
155pub enum ColorScheme {
156 Light,
158 Dark,
160 NoPreference,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "kebab-case")]
167pub enum ReducedMotion {
168 Reduce,
170 NoPreference,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "kebab-case")]
177pub enum ForcedColors {
178 Active,
180 None,
182}
183
184#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
190pub struct FingerprintProfile {
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub user_agent: Option<String>,
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub user_agent_data: Option<UserAgentData>,
197 #[serde(skip_serializing_if = "Option::is_none")]
199 pub accept_language: Option<String>,
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub languages: Option<Vec<String>>,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub locale: Option<String>,
206 #[serde(skip_serializing_if = "Option::is_none")]
208 pub timezone_id: Option<String>,
209 #[serde(skip_serializing_if = "Option::is_none")]
211 pub platform: Option<String>,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub vendor: Option<String>,
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub hardware_concurrency: Option<u32>,
218 #[serde(skip_serializing_if = "Option::is_none")]
220 pub device_memory: Option<f64>,
221 #[serde(skip_serializing_if = "Option::is_none")]
223 pub max_touch_points: Option<u32>,
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub do_not_track: Option<String>,
227 #[serde(skip_serializing_if = "Option::is_none")]
229 pub screen: Option<ScreenProfile>,
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub viewport: Option<ViewportProfile>,
233 #[serde(skip_serializing_if = "Option::is_none")]
235 pub webgl: Option<WebglProfile>,
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub geolocation: Option<GeolocationProfile>,
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub color_scheme: Option<ColorScheme>,
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub reduced_motion: Option<ReducedMotion>,
245 #[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
308fn 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
327fn 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
343pub 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 pub fn resolve(&self) -> Result<FingerprintProfile> {
387 resolve_fingerprint_profile(self)
388 }
389
390 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 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 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 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 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 pub fn locale(mut self, locale: impl Into<String>) -> Self {
453 self.locale = Some(locale.into());
454 self
455 }
456
457 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 pub fn platform(mut self, platform: impl Into<String>) -> Self {
465 self.platform = Some(platform.into());
466 self
467 }
468
469 pub fn vendor(mut self, vendor: impl Into<String>) -> Self {
471 self.vendor = Some(vendor.into());
472 self
473 }
474
475 pub fn hardware_concurrency(mut self, cores: u32) -> Self {
477 self.hardware_concurrency = Some(cores);
478 self
479 }
480
481 pub fn device_memory(mut self, gigabytes: f64) -> Self {
483 self.device_memory = Some(gigabytes);
484 self
485 }
486
487 pub fn max_touch_points(mut self, points: u32) -> Self {
489 self.max_touch_points = Some(points);
490 self
491 }
492
493 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 pub fn screen(mut self, screen: ScreenProfile) -> Self {
501 self.screen = Some(screen);
502 self
503 }
504
505 pub fn viewport(mut self, viewport: ViewportProfile) -> Self {
507 self.viewport = Some(viewport);
508 self
509 }
510
511 pub fn webgl(mut self, webgl: WebglProfile) -> Self {
513 self.webgl = Some(webgl);
514 self
515 }
516
517 pub fn geolocation(mut self, geolocation: GeolocationProfile) -> Self {
519 self.geolocation = Some(geolocation);
520 self
521 }
522
523 pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
525 self.color_scheme = Some(color_scheme);
526 self
527 }
528
529 pub fn reduced_motion(mut self, reduced_motion: ReducedMotion) -> Self {
531 self.reduced_motion = Some(reduced_motion);
532 self
533 }
534
535 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 #[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 #[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 north_south_error = FingerprintProfile::default()
853 .geolocation(GeolocationProfile {
854 latitude: 91.0,
855 longitude: 0.0,
856 accuracy: None,
857 })
858 .resolve()
859 .expect_err("an impossible latitude is rejected");
860 let east_west_error = FingerprintProfile::default()
861 .geolocation(GeolocationProfile {
862 latitude: 0.0,
863 longitude: -181.0,
864 accuracy: None,
865 })
866 .resolve()
867 .expect_err("an impossible longitude is rejected");
868
869 assert!(
870 north_south_error
871 .to_string()
872 .contains("latitude must be between -90 and 90"),
873 "{north_south_error}"
874 );
875 assert!(
876 east_west_error
877 .to_string()
878 .contains("longitude must be between -180 and 180"),
879 "{east_west_error}"
880 );
881 }
882
883 #[test]
884 fn rejects_an_unsupported_enum_value() {
885 let error = serde_json::from_str::<FingerprintProfile>(r#"{"colorScheme": "sepia"}"#)
886 .expect_err("an unknown color scheme is rejected");
887
888 assert!(error.to_string().contains("sepia"), "{error}");
889 }
890
891 #[test]
892 fn rejects_an_empty_languages_list() {
893 let empty: [String; 0] = [];
894 let error = FingerprintProfile::default()
895 .languages(empty)
896 .resolve()
897 .expect_err("an empty language list is rejected");
898
899 assert!(
900 error.to_string().contains("languages must not be empty"),
901 "{error}"
902 );
903 }
904
905 #[test]
906 fn round_trips_through_the_camel_case_names_the_protocol_uses() {
907 let profile = FingerprintProfile::default()
908 .hardware_concurrency(8)
909 .timezone_id("UTC")
910 .color_scheme(ColorScheme::NoPreference)
911 .resolve()
912 .expect("resolves");
913 let json = serde_json::to_value(&profile).expect("serializes");
914
915 assert_eq!(
916 json,
917 serde_json::json!({
918 "timezoneId": "UTC",
919 "hardwareConcurrency": 8,
920 "colorScheme": "no-preference",
921 })
922 );
923 assert_eq!(
924 serde_json::from_value::<FingerprintProfile>(json).expect("deserializes"),
925 profile
926 );
927 }
928}