Skip to main content

autopush_common/util/
user_agent.rs

1use woothee::parser::Parser;
2
3// List of valid user-agent attributes to keep, anything not in this
4// list is considered 'Other'. We log the user-agent on connect always
5// to retain the full string, but for DD more tags are expensive so we
6// limit to these.
7const VALID_UA_BROWSER: &[&str] = &["Chrome", "Firefox", "Safari", "Opera"];
8
9// See dataset.rs in https://github.com/woothee/woothee-rust for the
10// full list (WootheeResult's 'os' field may fall back to its 'name'
11// field). Windows has many values and we only care that its Windows
12//
13// The mobile entries matter for the bridge registration API, which only
14// mobile clients call: without them every iOS and Android check-in collapses
15// into `Other`, hiding the platform split that separates APNS from FCM.
16// iOS reports the device rather than the OS, so it arrives as three values.
17const VALID_UA_OS: &[&str] = &[
18    "Firefox OS",
19    "Linux",
20    "Mac OSX",
21    "Android",
22    "iPhone",
23    "iPad",
24    "iPod",
25];
26
27/// The tag value for a user agent we could not resolve.
28const UA_METRIC_UNKNOWN: &str = "Other";
29
30#[derive(Debug)]
31pub struct UserAgentInfo {
32    _user_agent_string: String,
33    pub category: String,
34    pub browser_name: String,
35    pub browser_version: String,
36    pub metrics_browser: String,
37    pub metrics_os: String,
38    pub os_version: String,
39    pub os: String,
40    // Note, Woothee can determine if a user agent is mobile if the
41    // ["smartphone", "mobilephone"].contains(category)
42}
43
44impl Default for UserAgentInfo {
45    /// Used when a request carries no `User-Agent` header at all.
46    ///
47    /// The raw fields stay empty so logs can tell a missing header from an
48    /// unparsable one, but the `metrics_*` fields take the same
49    /// [UA_METRIC_UNKNOWN] bucket an unparsable header gets, to avoid
50    /// sending empty tags to metrics backend.
51    fn default() -> Self {
52        Self {
53            _user_agent_string: String::new(),
54            category: String::new(),
55            browser_name: String::new(),
56            browser_version: String::new(),
57            metrics_browser: UA_METRIC_UNKNOWN.to_owned(),
58            metrics_os: UA_METRIC_UNKNOWN.to_owned(),
59            os_version: String::new(),
60            os: String::new(),
61        }
62    }
63}
64
65impl From<&str> for UserAgentInfo {
66    fn from(user_agent_string: &str) -> Self {
67        let parser = Parser::new();
68        let wresult = parser.parse(user_agent_string).unwrap_or_default();
69
70        // Determine a base os/browser for metrics' tags
71        let metrics_os = if wresult.os.starts_with("Windows") {
72            "Windows"
73        } else if VALID_UA_OS.contains(&wresult.os) {
74            wresult.os
75        } else {
76            UA_METRIC_UNKNOWN
77        };
78        let metrics_browser = if VALID_UA_BROWSER.contains(&wresult.name) {
79            wresult.name
80        } else {
81            UA_METRIC_UNKNOWN
82        };
83
84        Self {
85            category: wresult.category.to_owned(),
86            browser_name: wresult.name.to_owned(),
87            browser_version: wresult.version.to_owned(),
88            metrics_browser: metrics_browser.to_owned(),
89            metrics_os: metrics_os.to_owned(),
90            os_version: wresult.os_version.to_string(),
91            os: wresult.os.to_owned(),
92            _user_agent_string: user_agent_string.to_owned(),
93        }
94    }
95}
96
97impl From<&actix_web::HttpRequest> for UserAgentInfo {
98    fn from(req: &actix_web::HttpRequest) -> UserAgentInfo {
99        if let Some(header) = req.headers().get(&actix_web::http::header::USER_AGENT) {
100            Self::from(header.to_str().unwrap_or("UNKNOWN"))
101        } else {
102            UserAgentInfo::default()
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::UserAgentInfo;
110
111    #[test]
112    fn test_linux() {
113        let agent = r#"Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.1.2) Gecko/20090807 Mandriva Linux/1.9.1.2-1.1mud2009.1 (2009.1) Firefox/3.5.2 FirePHP/0.3,gzip(gfe),gzip(gfe)"#;
114        let ua_result = UserAgentInfo::from(agent);
115        assert_eq!(ua_result.metrics_os, "Linux");
116        assert_eq!(ua_result.os, "Linux");
117        assert_eq!(ua_result.metrics_browser, "Firefox");
118    }
119
120    #[test]
121    fn test_windows() {
122        let agent = r#"Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US; rv:1.9.2.3) Gecko/20100401 Firefox/3.6.3 (.NET CLR 3.5.30729)"#;
123        let ua_result = UserAgentInfo::from(agent);
124        assert_eq!(ua_result.metrics_os, "Windows");
125        assert_eq!(ua_result.os, "Windows 7");
126        assert_eq!(ua_result.metrics_browser, "Firefox");
127    }
128
129    #[test]
130    fn test_osx() {
131        let agent =
132            r#"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.5; rv:2.1.1) Gecko/ Firefox/5.0.1"#;
133        let ua_result = UserAgentInfo::from(agent);
134        assert_eq!(ua_result.metrics_os, "Mac OSX");
135        assert_eq!(ua_result.os, "Mac OSX");
136        assert_eq!(ua_result.metrics_browser, "Firefox");
137    }
138
139    /// Firefox iOS reports the device, not the OS, so each iOS device kind is
140    /// its own `metrics_os` value.
141    #[test]
142    fn test_firefox_ios() {
143        let agent = r#"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/127.0 Mobile/15E148 Safari/605.1.15"#;
144        let ua_result = UserAgentInfo::from(agent);
145        assert_eq!(ua_result.metrics_os, "iPhone");
146        assert_eq!(ua_result.os, "iPhone");
147        // woothee resolves `FxiOS/` to Firefox despite the WebKit shell, so
148        // the browser can't distinguish iOS from Android. `os` is what does.
149        assert_eq!(ua_result.metrics_browser, "Firefox");
150
151        let ipad = r#"Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/127.0 Mobile/15E148 Safari/605.1.15"#;
152        assert_eq!(UserAgentInfo::from(ipad).metrics_os, "iPad");
153    }
154
155    #[test]
156    fn test_firefox_android() {
157        let agent = r#"Mozilla/5.0 (Android 14; Mobile; rv:127.0) Gecko/127.0 Firefox/127.0"#;
158        let ua_result = UserAgentInfo::from(agent);
159        assert_eq!(ua_result.metrics_os, "Android");
160        assert_eq!(ua_result.os, "Android");
161        assert_eq!(ua_result.metrics_browser, "Firefox");
162    }
163
164    /// A request with no `User-Agent` header must still produce a usable tag
165    /// value (default).
166    #[test]
167    fn test_missing_user_agent() {
168        let ua_result = UserAgentInfo::default();
169        assert_eq!(ua_result.metrics_os, "Other");
170        assert_eq!(ua_result.metrics_browser, "Other");
171        // the raw fields stay empty, so logs can still tell a missing header
172        // apart from an unparsable one (which yields woothee's "UNKNOWN")
173        assert_eq!(ua_result.os, "");
174        assert_eq!(ua_result.browser_name, "");
175    }
176
177    #[test]
178    fn test_other() {
179        let agent =
180            r#"BlackBerry9000/4.6.0.167 Profile/MIDP-2.0 Configuration/CLDC-1.1 VendorID/102"#;
181        let ua_result = UserAgentInfo::from(agent);
182        assert_eq!(ua_result.metrics_os, "Other");
183        assert_eq!(ua_result.os, "BlackBerry");
184        assert_eq!(ua_result.metrics_browser, "Other");
185        assert_eq!(ua_result.browser_name, "UNKNOWN");
186    }
187}