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 && let Some(browser_context) = BrowserContext::from_hints_or_ua(user_agent_info)
51 {
52 contexts.add(browser_context);
53 }
54
55 if !contexts.contains::<DeviceContext>()
56 && let Some(device_context) = DeviceContext::from_hints_or_ua(user_agent_info)
57 {
58 contexts.add(device_context);
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 && let Some(os_context) = OsContext::from_hints_or_ua(user_agent_info)
74 {
75 contexts.insert(os_context_key.to_owned(), Context::Os(Box::new(os_context)));
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 && !headers.contains(key)
136 {
137 headers.insert(HeaderName::new(key), Annotated::new(HeaderValue::new(val)));
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 && let Some(k) = o_k.as_str()
187 {
188 contexts.set_ua_field_from_header(k, v.as_str());
189 }
190 }
191 contexts
192 }
193}
194
195#[derive(Clone, Copy, 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 {
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
288impl ClientHints<&str> {
289 pub fn to_owned(&self) -> ClientHints<String> {
291 ClientHints {
292 sec_ch_ua_platform: self.sec_ch_ua_platform.map(Into::into),
293 sec_ch_ua_platform_version: self.sec_ch_ua_platform_version.map(Into::into),
294 sec_ch_ua: self.sec_ch_ua.map(Into::into),
295 sec_ch_ua_model: self.sec_ch_ua_model.map(Into::into),
296 }
297 }
298}
299
300pub trait FromUserAgentInfo: Sized {
302 fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self>;
304
305 fn parse_user_agent(user_agent: &str) -> Option<Self>;
307
308 fn from_hints_or_ua(raw_info: &RawUserAgentInfo<&str>) -> Option<Self> {
310 Self::parse_client_hints(&raw_info.client_hints)
311 .or_else(|| raw_info.user_agent.and_then(Self::parse_user_agent))
312 }
313}
314
315impl FromUserAgentInfo for DeviceContext {
316 fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self> {
317 let device = client_hints.sec_ch_ua_model?.trim().replace('\"', "");
318
319 if device.is_empty() {
320 return None;
321 }
322
323 Some(Self {
324 model: Annotated::new(device),
325 ..Default::default()
326 })
327 }
328
329 fn parse_user_agent(user_agent: &str) -> Option<Self> {
330 let device = relay_ua::parse_device(user_agent);
331
332 if !is_known(&device.family) {
333 return None;
334 }
335
336 Some(Self {
337 family: Annotated::new(device.family.into_owned()),
338 model: Annotated::from(device.model.map(|cow| cow.into_owned())),
339 brand: Annotated::from(device.brand.map(|cow| cow.into_owned())),
340 ..DeviceContext::default()
341 })
342 }
343}
344
345impl FromUserAgentInfo for BrowserContext {
346 fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self> {
347 let (mut browser, version) = browser_from_client_hints(client_hints.sec_ch_ua?)?;
348
349 if browser == "Google Chrome" {
351 browser = "Chrome".to_owned();
352 }
353
354 Some(Self {
355 name: Annotated::new(browser),
356 version: Annotated::new(version),
357 ..Default::default()
358 })
359 }
360
361 fn parse_user_agent(user_agent: &str) -> Option<Self> {
362 let mut browser = relay_ua::parse_user_agent(user_agent);
363
364 if !is_known(&browser.family) {
365 return None;
366 }
367
368 if browser.family == "Google Chrome" {
370 browser.family = "Chrome".into();
371 }
372
373 Some(Self {
374 name: Annotated::from(browser.family.into_owned()),
375 version: Annotated::from(get_version(&browser.major, &browser.minor, &browser.patch)),
376 ..BrowserContext::default()
377 })
378 }
379}
380
381pub fn browser_from_client_hints(s: &str) -> Option<(String, String)> {
390 static UA_RE: OnceLock<Regex> = OnceLock::new();
391 let regex = UA_RE.get_or_init(|| Regex::new(r#""([^"]*)";v="([^"]*)""#).unwrap());
392 for item in s.split(',') {
393 if item.contains("Brand")
396 || item.contains("Chromium")
397 || item.contains("Gecko") || item.contains("WebKit")
399 {
400 continue;
401 }
402
403 let captures = regex.captures(item)?;
404
405 let browser = captures.get(1)?.as_str().to_owned();
406 let version = captures.get(2)?.as_str().to_owned();
407
408 if browser.trim().is_empty() || version.trim().is_empty() {
409 return None;
410 }
411
412 return Some((browser, version));
413 }
414 None
415}
416
417impl FromUserAgentInfo for OsContext {
418 fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self> {
419 let platform = client_hints.sec_ch_ua_platform?.trim().replace('\"', "");
420
421 if platform.is_empty() {
425 return None;
426 }
427
428 let version = client_hints
429 .sec_ch_ua_platform_version
430 .map(|version| version.trim().replace('\"', ""));
431
432 Some(Self {
433 name: Annotated::new(platform),
434 version: Annotated::from(version),
435 ..Default::default()
436 })
437 }
438
439 fn parse_user_agent(user_agent: &str) -> Option<Self> {
440 let os = relay_ua::parse_os(user_agent);
441 let mut version = get_version(&os.major, &os.minor, &os.patch);
442
443 if !is_known(&os.family) {
444 return None;
445 }
446
447 let name = os.family.into_owned();
448
449 if name == "Windows" {
452 if let Some(v) = version.as_mut()
453 && v == "10"
454 {
455 v.insert_str(0, ">=");
456 }
457 } else if name == "Mac OS X"
458 && let Some(v) = version.as_mut()
459 && v == "10.15.7"
460 {
461 v.insert_str(0, ">=");
462 }
463
464 Some(Self {
465 name: Annotated::new(name),
466 version: Annotated::from(version),
467 ..OsContext::default()
468 })
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use relay_event_schema::protocol::{PairList, Request};
475 use relay_protocol::assert_annotated_snapshot;
476
477 use super::*;
478
479 fn get_event_with_user_agent(user_agent: &str) -> Event {
481 let headers = vec![
482 Annotated::new((
483 Annotated::new("Accept".to_owned().into()),
484 Annotated::new("application/json".to_owned().into()),
485 )),
486 Annotated::new((
487 Annotated::new("UsEr-AgeNT".to_owned().into()),
488 Annotated::new(user_agent.to_owned().into()),
489 )),
490 Annotated::new((
491 Annotated::new("WWW-Authenticate".to_owned().into()),
492 Annotated::new("basic".to_owned().into()),
493 )),
494 ];
495
496 Event {
497 request: Annotated::new(Request {
498 headers: Annotated::new(Headers(PairList(headers))),
499 ..Request::default()
500 }),
501 ..Event::default()
502 }
503 }
504
505 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";
506
507 #[test]
508 fn test_version_none() {
509 assert_eq!(get_version(&None, &None, &None), None);
510 }
511
512 #[test]
513 fn test_version_major() {
514 assert_eq!(
515 get_version(&Some("X".into()), &None, &None),
516 Some("X".into())
517 )
518 }
519
520 #[test]
521 fn test_version_major_minor() {
522 assert_eq!(
523 get_version(&Some("X".into()), &Some("Y".into()), &None),
524 Some("X.Y".into())
525 )
526 }
527
528 #[test]
529 fn test_version_major_minor_patch() {
530 assert_eq!(
531 get_version(&Some("X".into()), &Some("Y".into()), &Some("Z".into())),
532 Some("X.Y.Z".into())
533 )
534 }
535
536 #[test]
537 fn test_verison_missing_minor() {
538 assert_eq!(
539 get_version(&Some("X".into()), &None, &Some("Z".into())),
540 Some("X".into())
541 )
542 }
543
544 #[test]
545 fn test_skip_no_user_agent() {
546 let mut event = Event::default();
547 normalize_user_agent(&mut event);
548 assert_eq!(event.contexts.value(), None);
549 }
550
551 #[test]
552 fn test_skip_unrecognizable_user_agent() {
553 let mut event = get_event_with_user_agent("a dont no");
554 normalize_user_agent(&mut event);
555 assert!(event.contexts.value().unwrap().0.is_empty());
556 }
557
558 #[test]
559 fn test_browser_context() {
560 let ua = "Mozilla/5.0 (-; -; -) - Chrome/18.0.1025.133 Mobile Safari/535.19";
561
562 let mut event = get_event_with_user_agent(ua);
563 normalize_user_agent(&mut event);
564 assert_annotated_snapshot!(event.contexts, @r###"
565 {
566 "browser": {
567 "name": "Chrome Mobile",
568 "version": "18.0.1025",
569 "type": "browser"
570 }
571 }
572 "###);
573 }
574
575 #[test]
576 fn test_os_context() {
577 let ua = "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) - -";
578
579 let mut event = get_event_with_user_agent(ua);
580 normalize_user_agent(&mut event);
581 assert_annotated_snapshot!(event.contexts, @r###"
582 {
583 "client_os": {
584 "name": "Windows",
585 "version": "7",
586 "type": "os"
587 }
588 }
589 "###);
590 }
591
592 #[test]
593 fn test_os_context_short_version() {
594 let ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 12_1 like Mac OS X) - (-)";
595 let mut event = get_event_with_user_agent(ua);
596 normalize_user_agent(&mut event);
597 assert_annotated_snapshot!(event.contexts, @r###"
598 {
599 "browser": {
600 "name": "Mobile Safari UI/WKWebView",
601 "type": "browser"
602 },
603 "client_os": {
604 "name": "iOS",
605 "version": "12.1",
606 "type": "os"
607 },
608 "device": {
609 "family": "iPhone",
610 "model": "iPhone",
611 "brand": "Apple",
612 "type": "device"
613 }
614 }
615 "###);
616 }
617
618 #[test]
619 fn test_os_context_full_version() {
620 let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) - (-)";
621 let mut event = get_event_with_user_agent(ua);
622 normalize_user_agent(&mut event);
623 assert_annotated_snapshot!(event.contexts, @r###"
624 {
625 "client_os": {
626 "name": "Mac OS X",
627 "version": "10.13.4",
628 "type": "os"
629 },
630 "device": {
631 "family": "Mac",
632 "model": "Mac",
633 "brand": "Apple",
634 "type": "device"
635 }
636 }
637 "###);
638 }
639
640 #[test]
641 fn test_device_context() {
642 let ua = "- (-; -; Galaxy Nexus Build/IMM76B) - (-) ";
643
644 let mut event = get_event_with_user_agent(ua);
645 normalize_user_agent(&mut event);
646 assert_annotated_snapshot!(event.contexts, @r###"
647 {
648 "device": {
649 "family": "Samsung Galaxy Nexus",
650 "model": "Galaxy Nexus",
651 "brand": "Samsung",
652 "type": "device"
653 }
654 }
655 "###);
656 }
657
658 #[test]
659 fn test_all_contexts() {
660 let mut event = get_event_with_user_agent(GOOD_UA);
661 normalize_user_agent(&mut event);
662 assert_annotated_snapshot!(event.contexts, @r###"
663 {
664 "browser": {
665 "name": "Chrome Mobile",
666 "version": "18.0.1025",
667 "type": "browser"
668 },
669 "client_os": {
670 "name": "Android",
671 "version": "4.0.4",
672 "type": "os"
673 },
674 "device": {
675 "family": "Samsung Galaxy Nexus",
676 "model": "Galaxy Nexus",
677 "brand": "Samsung",
678 "type": "device"
679 }
680 }
681 "###);
682 }
683
684 #[test]
685 fn test_user_agent_does_not_override_prefilled() {
686 let mut event = get_event_with_user_agent(GOOD_UA);
687 let mut contexts = Contexts::new();
688 contexts.add(BrowserContext {
689 name: Annotated::from("BR_FAMILY".to_owned()),
690 version: Annotated::from("BR_VERSION".to_owned()),
691 ..BrowserContext::default()
692 });
693 contexts.add(DeviceContext {
694 family: Annotated::from("DEV_FAMILY".to_owned()),
695 model: Annotated::from("DEV_MODEL".to_owned()),
696 brand: Annotated::from("DEV_BRAND".to_owned()),
697 ..DeviceContext::default()
698 });
699 contexts.add(OsContext {
700 name: Annotated::from("OS_FAMILY".to_owned()),
701 version: Annotated::from("OS_VERSION".to_owned()),
702 ..OsContext::default()
703 });
704
705 event.contexts = Annotated::new(contexts);
706
707 normalize_user_agent(&mut event);
708 assert_annotated_snapshot!(event.contexts, @r###"
709 {
710 "browser": {
711 "name": "BR_FAMILY",
712 "version": "BR_VERSION",
713 "type": "browser"
714 },
715 "client_os": {
716 "name": "Android",
717 "version": "4.0.4",
718 "type": "os"
719 },
720 "device": {
721 "family": "DEV_FAMILY",
722 "model": "DEV_MODEL",
723 "brand": "DEV_BRAND",
724 "type": "device"
725 },
726 "os": {
727 "name": "OS_FAMILY",
728 "version": "OS_VERSION",
729 "type": "os"
730 }
731 }
732 "###);
733 }
734
735 #[test]
736 fn test_fallback_to_ua_if_no_client_hints() {
737 let headers = Headers([
738 Annotated::new((
739 Annotated::new("user-agent".to_owned().into()),
740 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_owned().into()),
741 )),
742 Annotated::new((
743 Annotated::new("invalid header".to_owned().into()),
744 Annotated::new("moto g31(w)".to_owned().into()),
745 )),
746 ].into_iter().collect());
747
748 let device = DeviceContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers));
749 assert_eq!(device.unwrap().family.as_str().unwrap(), "foo g31(w)");
750 }
751 #[test]
752 fn test_use_client_hints_for_device() {
753 let headers = Headers([
754 Annotated::new((
755 Annotated::new("user-agent".to_owned().into()),
756 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_owned().into()),
757 )),
758 Annotated::new((
759 Annotated::new("SEC-CH-UA-MODEL".to_owned().into()),
760 Annotated::new("moto g31(w)".to_owned().into()),
761 )),
762 ].into_iter().collect());
763
764 let device = DeviceContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers));
765 assert_eq!(device.unwrap().model.as_str().unwrap(), "moto g31(w)");
766 }
767
768 #[test]
769 fn test_strip_whitespace_and_quotes() {
770 let headers = Headers(
771 [Annotated::new((
772 Annotated::new("SEC-CH-UA-MODEL".to_owned().into()),
773 Annotated::new(" \"moto g31(w)\"".to_owned().into()),
774 ))]
775 .into_iter()
776 .collect(),
777 );
778
779 let device = DeviceContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers));
780 assert_eq!(device.unwrap().model.as_str().unwrap(), "moto g31(w)");
781 }
782
783 #[test]
784 fn test_ignore_empty_device() {
785 let headers = Headers(
786 [Annotated::new((
787 Annotated::new("SEC-CH-UA-MODEL".to_owned().into()),
788 Annotated::new("".to_owned().into()),
789 ))]
790 .into_iter()
791 .collect(),
792 );
793
794 let client_hints = RawUserAgentInfo::from_headers(&headers).client_hints;
795 let from_hints = DeviceContext::parse_client_hints(&client_hints);
796 assert!(from_hints.is_none())
797 }
798
799 #[test]
800 fn test_client_hint_parser() {
801 let chrome = browser_from_client_hints(
802 r#"Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"#,
803 )
804 .unwrap();
805 assert_eq!(chrome.0, "Google Chrome".to_owned());
806 assert_eq!(chrome.1, "109".to_owned());
807
808 let opera = browser_from_client_hints(
809 r#""Chromium";v="108", "Opera";v="94", "Not)A;Brand";v="99""#,
810 )
811 .unwrap();
812 assert_eq!(opera.0, "Opera".to_owned());
813 assert_eq!(opera.1, "94".to_owned());
814
815 let mystery_browser = browser_from_client_hints(
816 r#""Chromium";v="108", "mystery-browser";v="94", "Not)A;Brand";v="99""#,
817 )
818 .unwrap();
819
820 assert_eq!(mystery_browser.0, "mystery-browser".to_owned());
821 assert_eq!(mystery_browser.1, "94".to_owned());
822 }
823
824 #[test]
825 fn test_client_hints_detected() {
826 let headers = Headers({
827 let headers = vec![
828 Annotated::new((
829 Annotated::new("user-agent".to_owned().into()),
830 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_owned().into()),
831 )),
832 Annotated::new((
833 Annotated::new("SEC-CH-UA".to_owned().into()),
834 Annotated::new(r#"Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"#.to_owned().into()),
835 )),
836 ];
837 PairList(headers)
838 });
839
840 let browser =
841 BrowserContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
842
843 insta::assert_debug_snapshot!(browser, @r###"
844 BrowserContext {
845 browser: ~,
846 name: "Chrome",
847 version: "109",
848 other: {},
849 }
850 "###);
851 }
852
853 #[test]
854 fn test_ignore_empty_browser() {
855 let headers = Headers({
856 let headers = vec![Annotated::new((
857 Annotated::new("SEC-CH-UA".to_owned().into()),
858 Annotated::new(
859 r#"Not_A Brand";v="99", " ";v="109", "Chromium";v="109"#
861 .to_owned()
862 .into(),
863 ),
864 ))];
865 PairList(headers)
866 });
867
868 let client_hints = RawUserAgentInfo::from_headers(&headers).client_hints;
869 let from_hints = BrowserContext::parse_client_hints(&client_hints);
870 assert!(from_hints.is_none())
871 }
872
873 #[test]
874 fn test_client_hints_with_unknown_browser() {
875 let headers = Headers({
876 let headers = vec![
877 Annotated::new((
878 Annotated::new("user-agent".to_owned().into()),
879 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_owned().into()),
880 )),
881 Annotated::new((
882 Annotated::new("SEC-CH-UA".to_owned().into()),
883 Annotated::new(r#"Not_A Brand";v="99", "weird browser";v="109", "Chromium";v="109"#.to_owned().into()),
884 )),
885 Annotated::new((
886 Annotated::new("SEC-CH-UA-FULL-VERSION".to_owned().into()),
887 Annotated::new("109.0.5414.87".to_owned().into()),
888 )),
889 ];
890 PairList(headers)
891 });
892
893 let browser =
894 BrowserContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
895
896 insta::assert_debug_snapshot!(browser, @r###"
897 BrowserContext {
898 browser: ~,
899 name: "weird browser",
900 version: "109",
901 other: {},
902 }
903 "###);
904 }
905
906 #[test]
907 fn fallback_on_ua_string_when_missing_browser_field() {
908 let headers = Headers({
909 let headers = vec![
910 Annotated::new((
911 Annotated::new("user-agent".to_owned().into()),
912 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_owned().into()),
913 )),
914 Annotated::new((
915 Annotated::new("SEC-CH-UA".to_owned().into()),
916 Annotated::new(r#"Not_A Brand";v="99", "Chromium";v="108"#.to_owned().into()), )),
918 ];
919 PairList(headers)
920 });
921
922 let browser =
923 BrowserContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
924 assert_eq!(
925 browser.version.as_str().unwrap(),
926 "109.0.0" );
928
929 assert_eq!("Chrome", browser.name.as_str().unwrap());
930 }
931
932 #[test]
933 fn test_strip_quotes() {
934 let headers = Headers({
935 let headers = vec![
936 Annotated::new((
937 Annotated::new("SEC-CH-UA-PLATFORM".to_owned().into()),
938 Annotated::new("\"macOS\"".to_owned().into()), )),
940 Annotated::new((
941 Annotated::new("SEC-CH-UA-PLATFORM-VERSION".to_owned().into()),
942 Annotated::new("\"13.1.0\"".to_owned().into()),
943 )),
944 ];
945 PairList(headers)
946 });
947 let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers));
948
949 assert_eq!(os.clone().unwrap().name.value().unwrap(), "macOS");
950 assert_eq!(os.unwrap().version.value().unwrap(), "13.1.0");
951 }
952
953 #[test]
955 fn test_choose_client_hints_for_os_context() {
956 let headers = Headers({
957 let headers = vec![
958 Annotated::new((
959 Annotated::new("user-agent".to_owned().into()),
960 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_owned().into()),
961 )),
962 Annotated::new((
963 Annotated::new("SEC-CH-UA-PLATFORM".to_owned().into()),
964 Annotated::new(r#"macOS"#.to_owned().into()), )),
966 Annotated::new((
967 Annotated::new("SEC-CH-UA-PLATFORM-VERSION".to_owned().into()),
968 Annotated::new("13.1.0".to_owned().into()),
969 )),
970 ];
971 PairList(headers)
972 });
973
974 let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
975
976 insta::assert_debug_snapshot!(os, @r###"
977 OsContext {
978 os: ~,
979 name: "macOS",
980 version: "13.1.0",
981 build: ~,
982 kernel_version: ~,
983 rooted: ~,
984 distribution_name: ~,
985 distribution_version: ~,
986 distribution_pretty_name: ~,
987 raw_description: ~,
988 other: {},
989 }
990 "###);
991 }
992
993 #[test]
994 fn test_ignore_empty_os() {
995 let headers = Headers({
996 let headers = vec![Annotated::new((
997 Annotated::new("SEC-CH-UA-PLATFORM".to_owned().into()),
998 Annotated::new(r#""#.to_owned().into()),
999 ))];
1000 PairList(headers)
1001 });
1002
1003 let client_hints = RawUserAgentInfo::from_headers(&headers).client_hints;
1004 let from_hints = OsContext::parse_client_hints(&client_hints);
1005 assert!(from_hints.is_none())
1006 }
1007
1008 #[test]
1009 fn test_keep_empty_os_version() {
1010 let headers = Headers({
1011 let headers = vec![
1012 Annotated::new((
1013 Annotated::new("SEC-CH-UA-PLATFORM".to_owned().into()),
1014 Annotated::new(r#"macOs"#.to_owned().into()),
1015 )),
1016 Annotated::new((
1017 Annotated::new("SEC-CH-UA-PLATFORM-VERSION".to_owned().into()),
1018 Annotated::new("".to_owned().into()),
1019 )),
1020 ];
1021 PairList(headers)
1022 });
1023
1024 let client_hints = RawUserAgentInfo::from_headers(&headers).client_hints;
1025 let from_hints = OsContext::parse_client_hints(&client_hints);
1026 assert!(from_hints.is_some())
1027 }
1028
1029 #[test]
1030 fn test_fallback_on_ua_string_for_os() {
1031 let headers = Headers({
1032 let headers = vec![
1033 Annotated::new((
1034 Annotated::new("user-agent".to_owned().into()),
1035 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_owned().into()),
1036 )),
1037 Annotated::new((
1038 Annotated::new("invalid header".to_owned().into()),
1039 Annotated::new(r#"macOS"#.to_owned().into()),
1040 )),
1041 Annotated::new((
1042 Annotated::new("SEC-CH-UA-PLATFORM-VERSION".to_owned().into()),
1043 Annotated::new("13.1.0".to_owned().into()),
1044 )),
1045 ];
1046 PairList(headers)
1047 });
1048
1049 let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
1050
1051 insta::assert_debug_snapshot!(os, @r###"
1052 OsContext {
1053 os: ~,
1054 name: "Mac OS X",
1055 version: "10.15.6",
1056 build: ~,
1057 kernel_version: ~,
1058 rooted: ~,
1059 distribution_name: ~,
1060 distribution_version: ~,
1061 distribution_pretty_name: ~,
1062 raw_description: ~,
1063 other: {},
1064 }
1065 "###);
1066 }
1067
1068 #[test]
1069 fn test_indicate_frozen_os_windows() {
1070 let headers = Headers({
1071 let headers = vec![
1072 Annotated::new((
1073 Annotated::new("user-agent".to_owned().into()),
1074 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_owned().into()),
1075 )),
1076 ];
1077 PairList(headers)
1078 });
1079
1080 let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
1081
1082 assert_eq!(os.version.value().unwrap(), ">=10");
1084 }
1085
1086 #[test]
1087 fn test_indicate_frozen_os_mac() {
1088 let headers = Headers({
1089 let headers = vec![
1090 Annotated::new((
1091 Annotated::new("user-agent".to_owned().into()),
1092 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_owned().into()),
1093 )),
1094 ];
1095 PairList(headers)
1096 });
1097
1098 let os = OsContext::from_hints_or_ua(&RawUserAgentInfo::from_headers(&headers)).unwrap();
1099
1100 assert_eq!(os.version.value().unwrap(), ">=10.15.7");
1102 }
1103
1104 #[test]
1105 fn test_default_empty() {
1106 assert!(RawUserAgentInfo::<&str>::default().is_empty());
1107 }
1108}