1use std::borrow::Cow;
13use std::fmt::Write;
14use std::sync::OnceLock;
15
16use regex::Regex;
17use relay_event_schema::protocol::{
18 BrowserContext, Context, Contexts, DefaultContext, DeviceContext, Event, HeaderName,
19 HeaderValue, Headers, OsContext,
20};
21use relay_protocol::Annotated;
22use serde::{Deserialize, Serialize};
23
24pub fn normalize_user_agent(event: &mut Event) {
26 let headers = match event
27 .request
28 .value()
29 .and_then(|request| request.headers.value())
30 {
31 Some(headers) => headers,
32 None => return,
33 };
34
35 let user_agent_info = RawUserAgentInfo::from_headers(headers);
36
37 let contexts = event.contexts.get_or_insert_with(Contexts::new);
38 normalize_user_agent_info_generic(contexts, &event.platform, &user_agent_info);
39}
40
41pub fn normalize_user_agent_info_generic(
45 contexts: &mut Contexts,
46 platform: &Annotated<String>,
47 user_agent_info: &RawUserAgentInfo<&str>,
48) {
49 if !contexts.contains::<BrowserContext>() {
50 if let Some(browser_context) = BrowserContext::from_hints_or_ua(user_agent_info) {
51 contexts.add(browser_context);
52 }
53 }
54
55 if !contexts.contains::<DeviceContext>() {
56 if let Some(device_context) = DeviceContext::from_hints_or_ua(user_agent_info) {
57 contexts.add(device_context);
58 }
59 }
60
61 let os_context_key = match platform.as_str() {
68 Some("javascript") => OsContext::default_key(),
69 _ => "client_os",
70 };
71
72 if !contexts.contains_key(os_context_key) {
73 if let Some(os_context) = OsContext::from_hints_or_ua(user_agent_info) {
74 contexts.insert(os_context_key.to_owned(), Context::Os(Box::new(os_context)));
75 }
76 }
77}
78
79fn is_known(family: &str) -> bool {
80 family != "Other"
81}
82
83fn get_version(
84 major: &Option<Cow<'_, str>>,
85 minor: &Option<Cow<'_, str>>,
86 patch: &Option<Cow<'_, str>>,
87) -> Option<String> {
88 let mut version = major.as_ref()?.to_string();
89
90 if let Some(minor) = minor {
91 write!(version, ".{minor}").ok();
92 if let Some(patch) = patch {
93 write!(version, ".{patch}").ok();
94 }
95 }
96
97 Some(version)
98}
99
100#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)]
105pub struct RawUserAgentInfo<S: Default + AsRef<str>> {
106 pub user_agent: Option<S>,
108 pub client_hints: ClientHints<S>,
110}
111
112impl<S: AsRef<str> + Default> RawUserAgentInfo<S> {
113 pub fn set_ua_field_from_header(&mut self, key: &str, value: Option<S>) {
116 match key.to_lowercase().as_str() {
117 "user-agent" => self.user_agent = value,
118
119 "sec-ch-ua" => self.client_hints.sec_ch_ua = value,
120 "sec-ch-ua-model" => self.client_hints.sec_ch_ua_model = value,
121 "sec-ch-ua-platform" => self.client_hints.sec_ch_ua_platform = value,
122 "sec-ch-ua-platform-version" => {
123 self.client_hints.sec_ch_ua_platform_version = value;
124 }
125 _ => {}
126 }
127 }
128
129 pub fn populate_event_headers(&self, headers: &mut Headers) {
133 let mut insert_header = |key: &str, val: Option<&S>| {
134 if let Some(val) = val {
135 if !headers.contains(key) {
136 headers.insert(HeaderName::new(key), Annotated::new(HeaderValue::new(val)));
137 }
138 }
139 };
140
141 insert_header(RawUserAgentInfo::USER_AGENT, self.user_agent.as_ref());
142 insert_header(
143 ClientHints::SEC_CH_UA_PLATFORM,
144 self.client_hints.sec_ch_ua_platform.as_ref(),
145 );
146 insert_header(
147 ClientHints::SEC_CH_UA_PLATFORM_VERSION,
148 self.client_hints.sec_ch_ua_platform_version.as_ref(),
149 );
150 insert_header(ClientHints::SEC_CH_UA, self.client_hints.sec_ch_ua.as_ref());
151 insert_header(
152 ClientHints::SEC_CH_UA_MODEL,
153 self.client_hints.sec_ch_ua_model.as_ref(),
154 );
155 }
156
157 pub fn is_empty(&self) -> bool {
159 self.user_agent.is_none() && self.client_hints.is_empty()
160 }
161}
162
163impl RawUserAgentInfo<String> {
164 pub const USER_AGENT: &'static str = "User-Agent";
166
167 pub fn as_deref(&self) -> RawUserAgentInfo<&str> {
169 RawUserAgentInfo::<&str> {
170 user_agent: self.user_agent.as_deref(),
171 client_hints: self.client_hints.as_deref(),
172 }
173 }
174}
175
176impl<'a> RawUserAgentInfo<&'a str> {
177 pub fn from_headers(headers: &'a Headers) -> Self {
182 let mut contexts: RawUserAgentInfo<&str> = Self::default();
183
184 for item in headers.iter() {
185 if let Some((o_k, v)) = item.value() {
186 if let Some(k) = o_k.as_str() {
187 contexts.set_ua_field_from_header(k, v.as_str());
188 }
189 }
190 }
191 contexts
192 }
193}
194
195#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
199pub struct ClientHints<S>
200where
201 S: Default + AsRef<str>,
202{
203 pub sec_ch_ua_platform: Option<S>,
205 pub sec_ch_ua_platform_version: Option<S>,
207 pub sec_ch_ua: Option<S>,
209 pub sec_ch_ua_model: Option<S>,
211}
212
213impl<S> ClientHints<S>
214where
215 S: AsRef<str> + Default,
216{
217 pub fn copy_from(&mut self, other: ClientHints<S>) {
220 if other.sec_ch_ua_platform_version.is_some() {
221 self.sec_ch_ua_platform_version = other.sec_ch_ua_platform_version;
222 }
223 if other.sec_ch_ua_platform.is_some() {
224 self.sec_ch_ua_platform = other.sec_ch_ua_platform;
225 }
226 if other.sec_ch_ua_model.is_some() {
227 self.sec_ch_ua_model = other.sec_ch_ua_model;
228 }
229 if other.sec_ch_ua.is_some() {
230 self.sec_ch_ua = other.sec_ch_ua;
231 }
232 }
233
234 pub fn is_empty(&self) -> bool {
236 self.sec_ch_ua_platform.is_none()
237 && self.sec_ch_ua_platform_version.is_none()
238 && self.sec_ch_ua.is_none()
239 && self.sec_ch_ua_model.is_none()
240 }
241}
242
243impl ClientHints<String> {
244 pub const SEC_CH_UA_PLATFORM: &'static str = "SEC-CH-UA-Platform";
251
252 pub const SEC_CH_UA_PLATFORM_VERSION: &'static str = "SEC-CH-UA-Platform-Version";
254
255 pub const SEC_CH_UA: &'static str = "SEC-CH-UA";
273
274 pub const SEC_CH_UA_MODEL: &'static str = "SEC-CH-UA-Model";
276
277 pub fn as_deref(&self) -> ClientHints<&str> {
279 ClientHints::<&str> {
280 sec_ch_ua_platform: self.sec_ch_ua_platform.as_deref(),
281 sec_ch_ua_platform_version: self.sec_ch_ua_platform_version.as_deref(),
282 sec_ch_ua: self.sec_ch_ua.as_deref(),
283 sec_ch_ua_model: self.sec_ch_ua_model.as_deref(),
284 }
285 }
286}
287
288pub trait FromUserAgentInfo: Sized {
290 fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self>;
292
293 fn parse_user_agent(user_agent: &str) -> Option<Self>;
295
296 fn from_hints_or_ua(raw_info: &RawUserAgentInfo<&str>) -> Option<Self> {
298 Self::parse_client_hints(&raw_info.client_hints)
299 .or_else(|| raw_info.user_agent.and_then(Self::parse_user_agent))
300 }
301}
302
303impl FromUserAgentInfo for DeviceContext {
304 fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self> {
305 let device = client_hints.sec_ch_ua_model?.trim().replace('\"', "");
306
307 if device.is_empty() {
308 return None;
309 }
310
311 Some(Self {
312 model: Annotated::new(device),
313 ..Default::default()
314 })
315 }
316
317 fn parse_user_agent(user_agent: &str) -> Option<Self> {
318 let device = relay_ua::parse_device(user_agent);
319
320 if !is_known(&device.family) {
321 return None;
322 }
323
324 Some(Self {
325 family: Annotated::new(device.family.into_owned()),
326 model: Annotated::from(device.model.map(|cow| cow.into_owned())),
327 brand: Annotated::from(device.brand.map(|cow| cow.into_owned())),
328 ..DeviceContext::default()
329 })
330 }
331}
332
333impl FromUserAgentInfo for BrowserContext {
334 fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self> {
335 let (mut browser, version) = browser_from_client_hints(client_hints.sec_ch_ua?)?;
336
337 if browser == "Google Chrome" {
339 browser = "Chrome".to_owned();
340 }
341
342 Some(Self {
343 name: Annotated::new(browser),
344 version: Annotated::new(version),
345 ..Default::default()
346 })
347 }
348
349 fn parse_user_agent(user_agent: &str) -> Option<Self> {
350 let mut browser = relay_ua::parse_user_agent(user_agent);
351
352 if !is_known(&browser.family) {
353 return None;
354 }
355
356 if browser.family == "Google Chrome" {
358 browser.family = "Chrome".into();
359 }
360
361 Some(Self {
362 name: Annotated::from(browser.family.into_owned()),
363 version: Annotated::from(get_version(&browser.major, &browser.minor, &browser.patch)),
364 ..BrowserContext::default()
365 })
366 }
367}
368
369pub fn browser_from_client_hints(s: &str) -> Option<(String, String)> {
378 static UA_RE: OnceLock<Regex> = OnceLock::new();
379 let regex = UA_RE.get_or_init(|| Regex::new(r#""([^"]*)";v="([^"]*)""#).unwrap());
380 for item in s.split(',') {
381 if item.contains("Brand")
384 || item.contains("Chromium")
385 || item.contains("Gecko") || item.contains("WebKit")
387 {
388 continue;
389 }
390
391 let captures = regex.captures(item)?;
392
393 let browser = captures.get(1)?.as_str().to_owned();
394 let version = captures.get(2)?.as_str().to_owned();
395
396 if browser.trim().is_empty() || version.trim().is_empty() {
397 return None;
398 }
399
400 return Some((browser, version));
401 }
402 None
403}
404
405impl FromUserAgentInfo for OsContext {
406 fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self> {
407 let platform = client_hints.sec_ch_ua_platform?.trim().replace('\"', "");
408
409 if platform.is_empty() {
413 return None;
414 }
415
416 let version = client_hints
417 .sec_ch_ua_platform_version
418 .map(|version| version.trim().replace('\"', ""));
419
420 Some(Self {
421 name: Annotated::new(platform),
422 version: Annotated::from(version),
423 ..Default::default()
424 })
425 }
426
427 fn parse_user_agent(user_agent: &str) -> Option<Self> {
428 let os = relay_ua::parse_os(user_agent);
429 let mut version = get_version(&os.major, &os.minor, &os.patch);
430
431 if !is_known(&os.family) {
432 return None;
433 }
434
435 let name = os.family.into_owned();
436
437 if name == "Windows" {
440 if let Some(v) = version.as_mut() {
441 if v == "10" {
442 v.insert_str(0, ">=");
443 }
444 }
445 } else if name == "Mac OS X" {
446 if let Some(v) = version.as_mut() {
447 if v == "10.15.7" {
448 v.insert_str(0, ">=");
449 }
450 }
451 }
452
453 Some(Self {
454 name: Annotated::new(name),
455 version: Annotated::from(version),
456 ..OsContext::default()
457 })
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use relay_event_schema::protocol::{PairList, Request};
464 use relay_protocol::assert_annotated_snapshot;
465
466 use super::*;
467
468 fn get_event_with_user_agent(user_agent: &str) -> Event {
470 let headers = vec![
471 Annotated::new((
472 Annotated::new("Accept".to_string().into()),
473 Annotated::new("application/json".to_string().into()),
474 )),
475 Annotated::new((
476 Annotated::new("UsEr-AgeNT".to_string().into()),
477 Annotated::new(user_agent.to_string().into()),
478 )),
479 Annotated::new((
480 Annotated::new("WWW-Authenticate".to_string().into()),
481 Annotated::new("basic".to_string().into()),
482 )),
483 ];
484
485 Event {
486 request: Annotated::new(Request {
487 headers: Annotated::new(Headers(PairList(headers))),
488 ..Request::default()
489 }),
490 ..Event::default()
491 }
492 }
493
494 const GOOD_UA: &str = "Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19";
495
496 #[test]
497 fn test_version_none() {
498 assert_eq!(get_version(&None, &None, &None), None);
499 }
500
501 #[test]
502 fn test_version_major() {
503 assert_eq!(
504 get_version(&Some("X".into()), &None, &None),
505 Some("X".into())
506 )
507 }
508
509 #[test]
510 fn test_version_major_minor() {
511 assert_eq!(
512 get_version(&Some("X".into()), &Some("Y".into()), &None),
513 Some("X.Y".into())
514 )
515 }
516
517 #[test]
518 fn test_version_major_minor_patch() {
519 assert_eq!(
520 get_version(&Some("X".into()), &Some("Y".into()), &Some("Z".into())),
521 Some("X.Y.Z".into())
522 )
523 }
524
525 #[test]
526 fn test_verison_missing_minor() {
527 assert_eq!(
528 get_version(&Some("X".into()), &None, &Some("Z".into())),
529 Some("X".into())
530 )
531 }
532
533 #[test]
534 fn test_skip_no_user_agent() {
535 let mut event = Event::default();
536 normalize_user_agent(&mut event);
537 assert_eq!(event.contexts.value(), None);
538 }
539
540 #[test]
541 fn test_skip_unrecognizable_user_agent() {
542 let mut event = get_event_with_user_agent("a dont no");
543 normalize_user_agent(&mut event);
544 assert!(event.contexts.value().unwrap().0.is_empty());
545 }
546
547 #[test]
548 fn test_browser_context() {
549 let ua = "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19";
550
551 let mut event = get_event_with_user_agent(ua);
552 normalize_user_agent(&mut event);
553 assert_annotated_snapshot!(event.contexts, @r#"
554 {
555 "browser": {
556 "name": "Chrome Mobile",
557 "version": "18.0.1025",
558 "type": "browser"
559 }
560 }
561 "#);
562 }
563
564 #[test]
565 fn test_os_context() {
566 let ua = "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) - -";
567
568 let mut event = get_event_with_user_agent(ua);
569 normalize_user_agent(&mut event);
570 assert_annotated_snapshot!(event.contexts, @r#"
571 {
572 "client_os": {
573 "name": "Windows",
574 "version": "7",
575 "type": "os"
576 }
577 }
578 "#);
579 }
580
581 #[test]
582 fn test_os_context_short_version() {
583 let ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 12_1 like Mac OS X) - (-)";
584 let mut event = get_event_with_user_agent(ua);
585 normalize_user_agent(&mut event);
586 assert_annotated_snapshot!(event.contexts, @r#"
587 {
588 "browser": {
589 "name": "Mobile Safari UI/WKWebView",
590 "type": "browser"
591 },
592 "client_os": {
593 "name": "iOS",
594 "version": "12.1",
595 "type": "os"
596 },
597 "device": {
598 "family": "iPhone",
599 "model": "iPhone",
600 "brand": "Apple",
601 "type": "device"
602 }
603 }
604 "#);
605 }
606
607 #[test]
608 fn test_os_context_full_version() {
609 let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) - (-)";
610 let mut event = get_event_with_user_agent(ua);
611 normalize_user_agent(&mut event);
612 assert_annotated_snapshot!(event.contexts, @r#"
613 {
614 "client_os": {
615 "name": "Mac OS X",
616 "version": "10.13.4",
617 "type": "os"
618 },
619 "device": {
620 "family": "Mac",
621 "model": "Mac",
622 "brand": "Apple",
623 "type": "device"
624 }
625 }
626 "#);
627 }
628
629 #[test]
630 fn test_device_context() {
631 let ua = "- (-; -; Galaxy Nexus Build/IMM76B) - (-) ";
632
633 let mut event = get_event_with_user_agent(ua);
634 normalize_user_agent(&mut event);
635 assert_annotated_snapshot!(event.contexts, @r#"
636 {
637 "device": {
638 "family": "Samsung Galaxy Nexus",
639 "model": "Galaxy Nexus",
640 "brand": "Samsung",
641 "type": "device"
642 }
643 }
644 "#);
645 }
646
647 #[test]
648 fn test_all_contexts() {
649 let mut event = get_event_with_user_agent(GOOD_UA);
650 normalize_user_agent(&mut event);
651 assert_annotated_snapshot!(event.contexts, @r#"
652 {
653 "browser": {
654 "name": "Chrome Mobile",
655 "version": "18.0.1025",
656 "type": "browser"
657 },
658 "client_os": {
659 "name": "Android",
660 "version": "4.0.4",
661 "type": "os"
662 },
663 "device": {
664 "family": "Samsung Galaxy Nexus",
665 "model": "Galaxy Nexus",
666 "brand": "Samsung",
667 "type": "device"
668 }
669 }
670 "#);
671 }
672
673 #[test]
674 fn test_user_agent_does_not_override_prefilled() {
675 let mut event = get_event_with_user_agent(GOOD_UA);
676 let mut contexts = Contexts::new();
677 contexts.add(BrowserContext {
678 name: Annotated::from("BR_FAMILY".to_string()),
679 version: Annotated::from("BR_VERSION".to_string()),
680 ..BrowserContext::default()
681 });
682 contexts.add(DeviceContext {
683 family: Annotated::from("DEV_FAMILY".to_string()),
684 model: Annotated::from("DEV_MODEL".to_string()),
685 brand: Annotated::from("DEV_BRAND".to_string()),
686 ..DeviceContext::default()
687 });
688 contexts.add(OsContext {
689 name: Annotated::from("OS_FAMILY".to_string()),
690 version: Annotated::from("OS_VERSION".to_string()),
691 ..OsContext::default()
692 });
693
694 event.contexts = Annotated::new(contexts);
695
696 normalize_user_agent(&mut event);
697 assert_annotated_snapshot!(event.contexts, @r#"
698 {
699 "browser": {
700 "name": "BR_FAMILY",
701 "version": "BR_VERSION",
702 "type": "browser"
703 },
704 "client_os": {
705 "name": "Android",
706 "version": "4.0.4",
707 "type": "os"
708 },
709 "device": {
710 "family": "DEV_FAMILY",
711 "model": "DEV_MODEL",
712 "brand": "DEV_BRAND",
713 "type": "device"
714 },
715 "os": {
716 "name": "OS_FAMILY",
717 "version": "OS_VERSION",
718 "type": "os"
719 }
720 }
721 "#);
722 }
723
724 #[test]
725 fn test_fallback_to_ua_if_no_client_hints() {
726 let headers = Headers([
727 Annotated::new((
728 Annotated::new("user-agent".to_string().into()),
729 Annotated::new(r#""Mozilla/5.0 (Linux; Android 11; foo g31(w)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Mobile Safari/537.36""#.to_string().into()),
730 )),
731 Annotated::new((
732 Annotated::new("invalid header".to_string().into()),
733 Annotated::new("moto g31(w)".to_string().into()),
734 )),
735 ].into_iter().collect());
736
737 let device = DeviceContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers));
738 assert_eq!(device.unwrap().family.as_str().unwrap(), "foo g31(w)");
739 }
740 #[test]
741 fn test_use_client_hints_for_device() {
742 let headers = Headers([
743 Annotated::new((
744 Annotated::new("user-agent".to_string().into()),
745 Annotated::new(r#""Mozilla/5.0 (Linux; Android 11; foo g31(w)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Mobile Safari/537.36""#.to_string().into()),
746 )),
747 Annotated::new((
748 Annotated::new("SEC-CH-UA-MODEL".to_string().into()),
749 Annotated::new("moto g31(w)".to_string().into()),
750 )),
751 ].into_iter().collect());
752
753 let device = DeviceContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers));
754 assert_eq!(device.unwrap().model.as_str().unwrap(), "moto g31(w)");
755 }
756
757 #[test]
758 fn test_strip_whitespace_and_quotes() {
759 let headers = Headers(
760 [Annotated::new((
761 Annotated::new("SEC-CH-UA-MODEL".to_string().into()),
762 Annotated::new(" \"moto g31(w)\"".to_string().into()),
763 ))]
764 .into_iter()
765 .collect(),
766 );
767
768 let device = DeviceContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers));
769 assert_eq!(device.unwrap().model.as_str().unwrap(), "moto g31(w)");
770 }
771
772 #[test]
773 fn test_ignore_empty_device() {
774 let headers = Headers(
775 [Annotated::new((
776 Annotated::new("SEC-CH-UA-MODEL".to_string().into()),
777 Annotated::new("".to_string().into()),
778 ))]
779 .into_iter()
780 .collect(),
781 );
782
783 let client_hints = RawUserAgentInfo::from_headers(&headers).client_hints;
784 let from_hints = DeviceContext::parse_client_hints(&client_hints);
785 assert!(from_hints.is_none())
786 }
787
788 #[test]
789 fn test_client_hint_parser() {
790 let chrome = browser_from_client_hints(
791 r#"Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"#,
792 )
793 .unwrap();
794 assert_eq!(chrome.0, "Google Chrome".to_owned());
795 assert_eq!(chrome.1, "109".to_owned());
796
797 let opera = browser_from_client_hints(
798 r#""Chromium";v="108", "Opera";v="94", "Not)A;Brand";v="99""#,
799 )
800 .unwrap();
801 assert_eq!(opera.0, "Opera".to_owned());
802 assert_eq!(opera.1, "94".to_owned());
803
804 let mystery_browser = browser_from_client_hints(
805 r#""Chromium";v="108", "mystery-browser";v="94", "Not)A;Brand";v="99""#,
806 )
807 .unwrap();
808
809 assert_eq!(mystery_browser.0, "mystery-browser".to_owned());
810 assert_eq!(mystery_browser.1, "94".to_owned());
811 }
812
813 #[test]
814 fn test_client_hints_detected() {
815 let headers = Headers({
816 let headers = vec![
817 Annotated::new((
818 Annotated::new("user-agent".to_string().into()),
819 Annotated::new(r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"#.to_string().into()),
820 )),
821 Annotated::new((
822 Annotated::new("SEC-CH-UA".to_string().into()),
823 Annotated::new(r#"Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"#.to_string().into()),
824 )),
825 ];
826 PairList(headers)
827 });
828
829 let browser =
830 BrowserContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
831
832 insta::assert_debug_snapshot!(browser, @r###"
833 BrowserContext {
834 browser: ~,
835 name: "Chrome",
836 version: "109",
837 other: {},
838 }
839 "###);
840 }
841
842 #[test]
843 fn test_ignore_empty_browser() {
844 let headers = Headers({
845 let headers = vec![Annotated::new((
846 Annotated::new("SEC-CH-UA".to_string().into()),
847 Annotated::new(
848 r#"Not_A Brand";v="99", " ";v="109", "Chromium";v="109"#
850 .to_string()
851 .into(),
852 ),
853 ))];
854 PairList(headers)
855 });
856
857 let client_hints = RawUserAgentInfo::from_headers(&headers).client_hints;
858 let from_hints = BrowserContext::parse_client_hints(&client_hints);
859 assert!(from_hints.is_none())
860 }
861
862 #[test]
863 fn test_client_hints_with_unknown_browser() {
864 let headers = Headers({
865 let headers = vec![
866 Annotated::new((
867 Annotated::new("user-agent".to_string().into()),
868 Annotated::new(r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"#.to_string().into()),
869 )),
870 Annotated::new((
871 Annotated::new("SEC-CH-UA".to_string().into()),
872 Annotated::new(r#"Not_A Brand";v="99", "weird browser";v="109", "Chromium";v="109"#.to_string().into()),
873 )),
874 Annotated::new((
875 Annotated::new("SEC-CH-UA-FULL-VERSION".to_string().into()),
876 Annotated::new("109.0.5414.87".to_string().into()),
877 )),
878 ];
879 PairList(headers)
880 });
881
882 let browser =
883 BrowserContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
884
885 insta::assert_debug_snapshot!(browser, @r###"
886 BrowserContext {
887 browser: ~,
888 name: "weird browser",
889 version: "109",
890 other: {},
891 }
892 "###);
893 }
894
895 #[test]
896 fn fallback_on_ua_string_when_missing_browser_field() {
897 let headers = Headers({
898 let headers = vec![
899 Annotated::new((
900 Annotated::new("user-agent".to_string().into()),
901 Annotated::new(r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"#.to_string().into()),
902 )),
903 Annotated::new((
904 Annotated::new("SEC-CH-UA".to_string().into()),
905 Annotated::new(r#"Not_A Brand";v="99", "Chromium";v="108"#.to_string().into()), )),
907 ];
908 PairList(headers)
909 });
910
911 let browser =
912 BrowserContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
913 assert_eq!(
914 browser.version.as_str().unwrap(),
915 "109.0.0" );
917
918 assert_eq!("Chrome", browser.name.as_str().unwrap());
919 }
920
921 #[test]
922 fn test_strip_quotes() {
923 let headers = Headers({
924 let headers = vec![
925 Annotated::new((
926 Annotated::new("SEC-CH-UA-PLATFORM".to_string().into()),
927 Annotated::new("\"macOS\"".to_string().into()), )),
929 Annotated::new((
930 Annotated::new("SEC-CH-UA-PLATFORM-VERSION".to_string().into()),
931 Annotated::new("\"13.1.0\"".to_string().into()),
932 )),
933 ];
934 PairList(headers)
935 });
936 let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers));
937
938 assert_eq!(os.clone().unwrap().name.value().unwrap(), "macOS");
939 assert_eq!(os.unwrap().version.value().unwrap(), "13.1.0");
940 }
941
942 #[test]
944 fn test_choose_client_hints_for_os_context() {
945 let headers = Headers({
946 let headers = vec![
947 Annotated::new((
948 Annotated::new("user-agent".to_string().into()),
949 Annotated::new(r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"#.to_string().into()),
950 )),
951 Annotated::new((
952 Annotated::new("SEC-CH-UA-PLATFORM".to_string().into()),
953 Annotated::new(r#"macOS"#.to_string().into()), )),
955 Annotated::new((
956 Annotated::new("SEC-CH-UA-PLATFORM-VERSION".to_string().into()),
957 Annotated::new("13.1.0".to_string().into()),
958 )),
959 ];
960 PairList(headers)
961 });
962
963 let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
964
965 insta::assert_debug_snapshot!(os, @r###"
966 OsContext {
967 os: ~,
968 name: "macOS",
969 version: "13.1.0",
970 build: ~,
971 kernel_version: ~,
972 rooted: ~,
973 distribution_name: ~,
974 distribution_version: ~,
975 distribution_pretty_name: ~,
976 raw_description: ~,
977 other: {},
978 }
979 "###);
980 }
981
982 #[test]
983 fn test_ignore_empty_os() {
984 let headers = Headers({
985 let headers = vec![Annotated::new((
986 Annotated::new("SEC-CH-UA-PLATFORM".to_string().into()),
987 Annotated::new(r#""#.to_string().into()),
988 ))];
989 PairList(headers)
990 });
991
992 let client_hints = RawUserAgentInfo::from_headers(&headers).client_hints;
993 let from_hints = OsContext::parse_client_hints(&client_hints);
994 assert!(from_hints.is_none())
995 }
996
997 #[test]
998 fn test_keep_empty_os_version() {
999 let headers = Headers({
1000 let headers = vec![
1001 Annotated::new((
1002 Annotated::new("SEC-CH-UA-PLATFORM".to_string().into()),
1003 Annotated::new(r#"macOs"#.to_string().into()),
1004 )),
1005 Annotated::new((
1006 Annotated::new("SEC-CH-UA-PLATFORM-VERSION".to_string().into()),
1007 Annotated::new("".to_string().into()),
1008 )),
1009 ];
1010 PairList(headers)
1011 });
1012
1013 let client_hints = RawUserAgentInfo::from_headers(&headers).client_hints;
1014 let from_hints = OsContext::parse_client_hints(&client_hints);
1015 assert!(from_hints.is_some())
1016 }
1017
1018 #[test]
1019 fn test_fallback_on_ua_string_for_os() {
1020 let headers = Headers({
1021 let headers = vec![
1022 Annotated::new((
1023 Annotated::new("user-agent".to_string().into()),
1024 Annotated::new(r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"#.to_string().into()),
1025 )),
1026 Annotated::new((
1027 Annotated::new("invalid header".to_string().into()),
1028 Annotated::new(r#"macOS"#.to_string().into()),
1029 )),
1030 Annotated::new((
1031 Annotated::new("SEC-CH-UA-PLATFORM-VERSION".to_string().into()),
1032 Annotated::new("13.1.0".to_string().into()),
1033 )),
1034 ];
1035 PairList(headers)
1036 });
1037
1038 let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
1039
1040 insta::assert_debug_snapshot!(os, @r###"
1041 OsContext {
1042 os: ~,
1043 name: "Mac OS X",
1044 version: "10.15.6",
1045 build: ~,
1046 kernel_version: ~,
1047 rooted: ~,
1048 distribution_name: ~,
1049 distribution_version: ~,
1050 distribution_pretty_name: ~,
1051 raw_description: ~,
1052 other: {},
1053 }
1054 "###);
1055 }
1056
1057 #[test]
1058 fn test_indicate_frozen_os_windows() {
1059 let headers = Headers({
1060 let headers = vec![
1061 Annotated::new((
1062 Annotated::new("user-agent".to_string().into()),
1063 Annotated::new(r#"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36"#.to_string().into()),
1064 )),
1065 ];
1066 PairList(headers)
1067 });
1068
1069 let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
1070
1071 assert_eq!(os.version.value().unwrap(), ">=10");
1073 }
1074
1075 #[test]
1076 fn test_indicate_frozen_os_mac() {
1077 let headers = Headers({
1078 let headers = vec![
1079 Annotated::new((
1080 Annotated::new("user-agent".to_string().into()),
1081 Annotated::new(r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"#.to_string().into()),
1082 )),
1083 ];
1084 PairList(headers)
1085 });
1086
1087 let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
1088
1089 assert_eq!(os.version.value().unwrap(), ">=10.15.7");
1091 }
1092
1093 #[test]
1094 fn test_default_empty() {
1095 assert!(RawUserAgentInfo::<&str>::default().is_empty());
1096 }
1097}