1const MAX_LABEL_LEN: usize = 80;
19
20pub fn parse_user_agent(ua: &str) -> String {
23 let ua = ua.trim();
24 if ua.is_empty() {
25 return "Unknown".into();
26 }
27 if let Some(label) = match_sdk(ua) {
31 return cap(label.to_string());
32 }
33 let browser = match_browser(ua);
34 let os = match_os(ua);
35 let label = match (browser, os) {
36 (Some(b), Some(o)) => format!("{b} on {o}"),
37 (Some(b), None) => b.to_string(),
38 (None, Some(o)) => o.to_string(),
39 (None, None) => "Unknown".into(),
40 };
41 cap(label)
42}
43
44fn cap(s: String) -> String {
45 if s.chars().count() <= MAX_LABEL_LEN {
46 s
47 } else {
48 s.chars().take(MAX_LABEL_LEN).collect()
49 }
50}
51
52fn match_sdk(ua: &str) -> Option<&'static str> {
55 let lc = ua.to_ascii_lowercase();
56 if lc.starts_with("pylonclient/") || lc.starts_with("pylonsdk/") {
57 return Some("Pylon SDK");
58 }
59 if lc.starts_with("pylon-cli/") || lc.starts_with("pylon/") {
60 return Some("Pylon CLI");
61 }
62 if lc.starts_with("curl/") {
63 return Some("curl");
64 }
65 if lc.starts_with("httpie/") || lc.starts_with("python-requests/") {
66 return Some("Python (requests)");
67 }
68 if lc.starts_with("go-http-client/") {
69 return Some("Go HTTP client");
70 }
71 if lc.starts_with("postmanruntime/") {
72 return Some("Postman");
73 }
74 None
75}
76
77fn match_browser(ua: &str) -> Option<&'static str> {
81 if ua.contains("Edg/") || ua.contains("Edge/") {
82 return Some("Edge");
83 }
84 if ua.contains("OPR/") || ua.contains("Opera") {
85 return Some("Opera");
86 }
87 if ua.contains("Brave") {
88 return Some("Brave");
89 }
90 if ua.contains("Vivaldi") {
91 return Some("Vivaldi");
92 }
93 if ua.contains("Firefox/") {
95 return Some("Firefox");
96 }
97 if ua.contains("Chrome/") {
100 return Some("Chrome");
101 }
102 if ua.contains("Safari/") {
103 return Some("Safari");
104 }
105 None
106}
107
108fn match_os(ua: &str) -> Option<&'static str> {
112 if ua.contains("iPhone") {
113 return Some("iOS");
114 }
115 if ua.contains("iPad") {
116 return Some("iPadOS");
117 }
118 if ua.contains("Android") {
119 return Some("Android");
120 }
121 if ua.contains("Macintosh") || ua.contains("Mac OS") {
123 return Some("macOS");
124 }
125 if ua.contains("Windows NT") || ua.contains("Win64") || ua.contains("Win32") {
126 return Some("Windows");
127 }
128 if ua.contains("CrOS") {
131 return Some("ChromeOS");
132 }
133 if ua.contains("Linux") {
134 return Some("Linux");
135 }
136 None
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn empty_returns_unknown() {
145 assert_eq!(parse_user_agent(""), "Unknown");
146 assert_eq!(parse_user_agent(" "), "Unknown");
147 }
148
149 #[test]
150 fn chrome_macos() {
151 let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
152 assert_eq!(parse_user_agent(ua), "Chrome on macOS");
153 }
154
155 #[test]
156 fn safari_ios() {
157 let ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
158 assert_eq!(parse_user_agent(ua), "Safari on iOS");
160 }
161
162 #[test]
163 fn firefox_linux() {
164 let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
165 assert_eq!(parse_user_agent(ua), "Firefox on Linux");
166 }
167
168 #[test]
169 fn edge_classified_before_chrome() {
170 let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0";
173 assert_eq!(parse_user_agent(ua), "Edge on Windows");
174 }
175
176 #[test]
177 fn opera_classified_before_chrome() {
178 let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 OPR/115.0.0.0";
179 assert_eq!(parse_user_agent(ua), "Opera on Windows");
180 }
181
182 #[test]
183 fn android_classified_before_linux() {
184 let ua = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36";
187 assert_eq!(parse_user_agent(ua), "Chrome on Android");
188 }
189
190 #[test]
191 fn ipad_classified_before_macos() {
192 let ua = "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
197 assert_eq!(parse_user_agent(ua), "Safari on iPadOS");
198 }
199
200 #[test]
201 fn pylon_sdk_recognized() {
202 assert_eq!(parse_user_agent("PylonClient/0.3.21 ts"), "Pylon SDK");
203 assert_eq!(parse_user_agent("PylonSDK/swift 0.3.21"), "Pylon SDK");
204 assert_eq!(parse_user_agent("pylon/0.3.21"), "Pylon CLI");
205 }
206
207 #[test]
208 fn curl_recognized() {
209 assert_eq!(parse_user_agent("curl/8.4.0"), "curl");
210 }
211
212 #[test]
213 fn capped_at_80_chars() {
214 let label = parse_user_agent(&("X".repeat(500)));
215 assert!(label.chars().count() <= MAX_LABEL_LEN);
216 }
217
218 #[test]
219 fn unknown_browser_known_os() {
220 let ua = "WeirdBrowser/1.0 (Windows NT 10.0)";
222 assert_eq!(parse_user_agent(ua), "Windows");
223 }
224
225 #[test]
226 fn unknown_browser_unknown_os() {
227 assert_eq!(parse_user_agent("totally-bogus-junk"), "Unknown");
228 }
229
230 #[test]
234 fn does_not_panic_on_pathological_input() {
235 let _ = parse_user_agent(&"\u{1F600}".repeat(10000)); let _ = parse_user_agent(&"\0\0\0".repeat(1000)); let _ = parse_user_agent("\n\n\n\n");
238 }
239}