1use anyhow::{bail, Result};
22use serde::{Deserialize, Serialize};
23
24use super::derive::derive_user_agent_data;
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct BrandVersion {
30 pub brand: String,
32 pub version: String,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
39pub struct UserAgentData {
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub brands: Option<Vec<BrandVersion>>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub full_version_list: Option<Vec<BrandVersion>>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub platform: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub platform_version: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub architecture: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub bitness: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
62 pub full_version: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub model: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub mobile: Option<bool>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub wow64: Option<bool>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub form_factors: Option<Vec<String>>,
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
80pub struct ScreenProfile {
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub width: Option<u32>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub height: Option<u32>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub avail_width: Option<u32>,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub avail_height: Option<u32>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub color_depth: Option<u32>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub pixel_depth: Option<u32>,
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
104pub struct ViewportProfile {
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub width: Option<u32>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub height: Option<u32>,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub device_scale_factor: Option<f64>,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub mobile: Option<bool>,
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase", deny_unknown_fields)]
122pub struct GeolocationProfile {
123 pub latitude: f64,
125 pub longitude: f64,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub accuracy: Option<f64>,
130}
131
132#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
135pub struct WebglProfile {
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub vendor: Option<String>,
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub renderer: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub unmasked_vendor: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub unmasked_renderer: Option<String>,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "kebab-case")]
153pub enum ColorScheme {
154 Light,
156 Dark,
158 NoPreference,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "kebab-case")]
165pub enum ReducedMotion {
166 Reduce,
168 NoPreference,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "kebab-case")]
175pub enum ForcedColors {
176 Active,
178 None,
180}
181
182#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
188pub struct FingerprintProfile {
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub user_agent: Option<String>,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub user_agent_data: Option<UserAgentData>,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub accept_language: Option<String>,
198 #[serde(skip_serializing_if = "Option::is_none")]
200 pub languages: Option<Vec<String>>,
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub locale: Option<String>,
204 #[serde(skip_serializing_if = "Option::is_none")]
206 pub timezone_id: Option<String>,
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub platform: Option<String>,
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub vendor: Option<String>,
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub hardware_concurrency: Option<u32>,
216 #[serde(skip_serializing_if = "Option::is_none")]
218 pub device_memory: Option<f64>,
219 #[serde(skip_serializing_if = "Option::is_none")]
221 pub max_touch_points: Option<u32>,
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub do_not_track: Option<String>,
225 #[serde(skip_serializing_if = "Option::is_none")]
227 pub screen: Option<ScreenProfile>,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub viewport: Option<ViewportProfile>,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub webgl: Option<WebglProfile>,
234 #[serde(skip_serializing_if = "Option::is_none")]
236 pub geolocation: Option<GeolocationProfile>,
237 #[serde(skip_serializing_if = "Option::is_none")]
239 pub color_scheme: Option<ColorScheme>,
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub reduced_motion: Option<ReducedMotion>,
243 #[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
306fn 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
325fn 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
341pub 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 pub fn resolve(&self) -> Result<FingerprintProfile> {
385 resolve_fingerprint_profile(self)
386 }
387
388 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 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 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 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 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 pub fn locale(mut self, locale: impl Into<String>) -> Self {
450 self.locale = Some(locale.into());
451 self
452 }
453
454 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 pub fn platform(mut self, platform: impl Into<String>) -> Self {
462 self.platform = Some(platform.into());
463 self
464 }
465
466 pub fn vendor(mut self, vendor: impl Into<String>) -> Self {
468 self.vendor = Some(vendor.into());
469 self
470 }
471
472 pub fn hardware_concurrency(mut self, cores: u32) -> Self {
474 self.hardware_concurrency = Some(cores);
475 self
476 }
477
478 pub fn device_memory(mut self, gigabytes: f64) -> Self {
480 self.device_memory = Some(gigabytes);
481 self
482 }
483
484 pub fn max_touch_points(mut self, points: u32) -> Self {
486 self.max_touch_points = Some(points);
487 self
488 }
489
490 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 pub fn screen(mut self, screen: ScreenProfile) -> Self {
498 self.screen = Some(screen);
499 self
500 }
501
502 pub fn viewport(mut self, viewport: ViewportProfile) -> Self {
504 self.viewport = Some(viewport);
505 self
506 }
507
508 pub fn webgl(mut self, webgl: WebglProfile) -> Self {
510 self.webgl = Some(webgl);
511 self
512 }
513
514 pub fn geolocation(mut self, geolocation: GeolocationProfile) -> Self {
516 self.geolocation = Some(geolocation);
517 self
518 }
519
520 pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
522 self.color_scheme = Some(color_scheme);
523 self
524 }
525
526 pub fn reduced_motion(mut self, reduced_motion: ReducedMotion) -> Self {
528 self.reduced_motion = Some(reduced_motion);
529 self
530 }
531
532 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 #[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 #[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}