relay_event_normalization/normalize/
user_agent.rs

1//! Generate context data from user agent and client hints.
2//!
3//! This module is responsible for taking the user agent string parsing it and filling in
4//! the browser, os and device information in the event.
5//!
6//! ## NOTICE
7//!
8//! Adding user_agent parsing to your module will incur a latency penalty in the test suite.
9//! Because of this some integration tests may fail. To fix this, you will need to add a timeout
10//! to your consumer.
11
12use 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
24/// Generates context data from client hints or user agent.
25pub 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
41/// Low-level version of [`normalize_user_agent`] operating on parts.
42///
43/// This can be used to create contexts from client information without a full [`Event`] instance.
44pub 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    // avoid conflicts with OS-context sent by a serverside SDK by using `contexts.client_os`
62    // instead of `contexts.os`. This is then preferred by the UI to show alongside device and
63    // browser context.
64    //
65    // Why not move the existing `contexts.os` into a different key on conflicts? Because we still
66    // want to index (derive tags from) the SDK-sent context.
67    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/// A container housing both the user-agent string and the client hint headers.
101///
102/// Useful for the scenarios where you will use either information from client hints if it exists,
103/// and if not fall back to user agent string.
104#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)]
105pub struct RawUserAgentInfo<S: Default + AsRef<str>> {
106    /// The "old style" of a single UA string.
107    pub user_agent: Option<S>,
108    /// User-Agent client hints.
109    pub client_hints: ClientHints<S>,
110}
111
112impl<S: AsRef<str> + Default> RawUserAgentInfo<S> {
113    /// Checks if key matches a user agent header, in which case it sets the value accordingly.
114    ///  TODO(tor): make it generic over different header types.
115    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    /// Convert user-agent info to HTTP headers as stored in the `Request` interface.
130    ///
131    /// This function does not overwrite any pre-existing headers.
132    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    /// Returns `true`, if neither a user agent nor client hints are available.
158    pub fn is_empty(&self) -> bool {
159        self.user_agent.is_none() && self.client_hints.is_empty()
160    }
161}
162
163impl RawUserAgentInfo<String> {
164    /// The name of the user agent HTTP header.
165    pub const USER_AGENT: &'static str = "User-Agent";
166
167    /// Converts to a borrowed `RawUserAgentInfo`.
168    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    /// Computes a borrowed `RawUserAgentInfo` from the given HTTP headers.
178    ///
179    /// This extracts both the user agent as well as client hints if available. Use
180    /// [`is_empty`](Self::is_empty) to check whether information could be extracted.
181    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/// The client hint variable names mirror the name of the "SEC-CH" headers.
196///
197/// See <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#user_agent_client_hints>
198#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
199pub struct ClientHints<S>
200where
201    S: Default + AsRef<str>,
202{
203    /// The client's OS, e.g. macos, android...
204    pub sec_ch_ua_platform: Option<S>,
205    /// The version number of the client's OS.
206    pub sec_ch_ua_platform_version: Option<S>,
207    /// Name of the client's web browser and its version.
208    pub sec_ch_ua: Option<S>,
209    /// Device model, e.g. samsung galaxy 3.
210    pub sec_ch_ua_model: Option<S>,
211}
212
213impl<S> ClientHints<S>
214where
215    S: AsRef<str> + Default,
216{
217    /// Checks every field of a passed-in ClientHints instance if it contains a value, and if it
218    /// does, copy it to self.
219    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    /// Checks if every field is of value None.
235    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    /// Provides the platform or operating system on which the user agent is running.
245    ///
246    /// For example: `"Windows"` or `"Android"`.
247    ///
248    /// `Sec-CH-UA-Platform` is a low entropy hint. Unless blocked by a user agent permission
249    /// policy, it is sent by default (without the server opting in by sending `Accept-CH`).
250    pub const SEC_CH_UA_PLATFORM: &'static str = "SEC-CH-UA-Platform";
251
252    /// Provides the version of the operating system on which the user agent is running.
253    pub const SEC_CH_UA_PLATFORM_VERSION: &'static str = "SEC-CH-UA-Platform-Version";
254
255    /// Provides the user agent's branding and significant version information.
256    ///
257    /// A brand is a commercial name for the user agent like: Chromium, Opera, Google Chrome,
258    /// Microsoft Edge, Firefox, and Safari. A user agent might have several associated brands. For
259    /// example, Opera, Chrome, and Edge are all based on Chromium, and will provide both brands in
260    /// the `Sec-CH-UA` header.
261    ///
262    /// The significant version is the "marketing" version identifier that is used to distinguish
263    /// between major releases of the brand. For example a Chromium build with full version number
264    /// "96.0.4664.45" has a significant version number of "96".
265    ///
266    /// The header may include "fake" brands in any position and with any name. This is a feature
267    /// designed to prevent servers from rejecting unknown user agents outright, forcing user agents
268    /// to lie about their brand identity.
269    ///
270    /// `Sec-CH-UA` is a low entropy hint. Unless blocked by a user agent permission policy, it is
271    /// sent by default (without the server opting in by sending `Accept-CH`).
272    pub const SEC_CH_UA: &'static str = "SEC-CH-UA";
273
274    /// Indicates the device model on which the browser is running.
275    pub const SEC_CH_UA_MODEL: &'static str = "SEC-CH-UA-Model";
276
277    /// Returns an instance of `ClientHints` that borrows from the original data.
278    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
288/// Computes a [`Context`] from either a user agent string and client hints.
289pub trait FromUserAgentInfo: Sized {
290    /// Tries to populate the context from client hints.
291    fn parse_client_hints(client_hints: &ClientHints<&str>) -> Option<Self>;
292
293    /// Tries to populate the context from a user agent header string.
294    fn parse_user_agent(user_agent: &str) -> Option<Self>;
295
296    /// Tries to populate the context from client hints or a user agent header string.
297    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        // Normalize "Google Chrome" to just "Chrome"
338        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        // Normalize "Google Chrome" to just "Chrome"
357        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
369/// The sec-ch-ua field looks something like this:
370/// "Not_A Brand";v="99", "Google Chrome";v="109", "Chromium";v="109"
371/// The order of the items are randomly shuffled.
372///
373/// It tries to detect the "not a brand" item and the browser engine, if it's neither its assumed
374/// to be a browser and gets returned as such.
375///
376/// Returns None if no browser field detected.
377pub 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 it contains one of these then we can know it isn't a browser field. atm chromium
382        // browsers are the only ones supporting client hints.
383        if item.contains("Brand")
384            || item.contains("Chromium")
385            || item.contains("Gecko") // useless until firefox and safari support client hints
386            || 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        // We only return early if the platform is empty, not the version number. This is because
410        // an empty version number might suggest that the user need to request additional
411        // client hints data.
412        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        // Since user-agent strings freeze the OS-version at windows 10 and mac os 10.15.7,
438        // we will indicate that the version may in reality be higher.
439        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    /// Creates an Event with the specified user agent.
469    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                    // browser field missing
849                    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()), // no browser field here
906            )),
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" // notice the version number is from UA string not from client hints
916        );
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()), // no browser field here
928                )),
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    /// Verifies that client hints are chosen over ua string when available.
943    #[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()), // no browser field here
954            )),
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        // Checks that the '>=' prefix is added.
1072        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        // Checks that the '>=' prefix is added.
1090        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}