const MAX_LABEL_LEN: usize = 80;
pub fn parse_user_agent(ua: &str) -> String {
let ua = ua.trim();
if ua.is_empty() {
return "Unknown".into();
}
if let Some(label) = match_sdk(ua) {
return cap(label.to_string());
}
let browser = match_browser(ua);
let os = match_os(ua);
let label = match (browser, os) {
(Some(b), Some(o)) => format!("{b} on {o}"),
(Some(b), None) => b.to_string(),
(None, Some(o)) => o.to_string(),
(None, None) => "Unknown".into(),
};
cap(label)
}
fn cap(s: String) -> String {
if s.chars().count() <= MAX_LABEL_LEN {
s
} else {
s.chars().take(MAX_LABEL_LEN).collect()
}
}
fn match_sdk(ua: &str) -> Option<&'static str> {
let lc = ua.to_ascii_lowercase();
if lc.starts_with("pylonclient/") || lc.starts_with("pylonsdk/") {
return Some("Pylon SDK");
}
if lc.starts_with("pylon-cli/") || lc.starts_with("pylon/") {
return Some("Pylon CLI");
}
if lc.starts_with("curl/") {
return Some("curl");
}
if lc.starts_with("httpie/") || lc.starts_with("python-requests/") {
return Some("Python (requests)");
}
if lc.starts_with("go-http-client/") {
return Some("Go HTTP client");
}
if lc.starts_with("postmanruntime/") {
return Some("Postman");
}
None
}
fn match_browser(ua: &str) -> Option<&'static str> {
if ua.contains("Edg/") || ua.contains("Edge/") {
return Some("Edge");
}
if ua.contains("OPR/") || ua.contains("Opera") {
return Some("Opera");
}
if ua.contains("Brave") {
return Some("Brave");
}
if ua.contains("Vivaldi") {
return Some("Vivaldi");
}
if ua.contains("Firefox/") {
return Some("Firefox");
}
if ua.contains("Chrome/") {
return Some("Chrome");
}
if ua.contains("Safari/") {
return Some("Safari");
}
None
}
fn match_os(ua: &str) -> Option<&'static str> {
if ua.contains("iPhone") {
return Some("iOS");
}
if ua.contains("iPad") {
return Some("iPadOS");
}
if ua.contains("Android") {
return Some("Android");
}
if ua.contains("Macintosh") || ua.contains("Mac OS") {
return Some("macOS");
}
if ua.contains("Windows NT") || ua.contains("Win64") || ua.contains("Win32") {
return Some("Windows");
}
if ua.contains("CrOS") {
return Some("ChromeOS");
}
if ua.contains("Linux") {
return Some("Linux");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_returns_unknown() {
assert_eq!(parse_user_agent(""), "Unknown");
assert_eq!(parse_user_agent(" "), "Unknown");
}
#[test]
fn chrome_macos() {
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";
assert_eq!(parse_user_agent(ua), "Chrome on macOS");
}
#[test]
fn safari_ios() {
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";
assert_eq!(parse_user_agent(ua), "Safari on iOS");
}
#[test]
fn firefox_linux() {
let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
assert_eq!(parse_user_agent(ua), "Firefox on Linux");
}
#[test]
fn edge_classified_before_chrome() {
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";
assert_eq!(parse_user_agent(ua), "Edge on Windows");
}
#[test]
fn opera_classified_before_chrome() {
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";
assert_eq!(parse_user_agent(ua), "Opera on Windows");
}
#[test]
fn android_classified_before_linux() {
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";
assert_eq!(parse_user_agent(ua), "Chrome on Android");
}
#[test]
fn ipad_classified_before_macos() {
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";
assert_eq!(parse_user_agent(ua), "Safari on iPadOS");
}
#[test]
fn pylon_sdk_recognized() {
assert_eq!(parse_user_agent("PylonClient/0.3.21 ts"), "Pylon SDK");
assert_eq!(parse_user_agent("PylonSDK/swift 0.3.21"), "Pylon SDK");
assert_eq!(parse_user_agent("pylon/0.3.21"), "Pylon CLI");
}
#[test]
fn curl_recognized() {
assert_eq!(parse_user_agent("curl/8.4.0"), "curl");
}
#[test]
fn capped_at_80_chars() {
let label = parse_user_agent(&("X".repeat(500)));
assert!(label.chars().count() <= MAX_LABEL_LEN);
}
#[test]
fn unknown_browser_known_os() {
let ua = "WeirdBrowser/1.0 (Windows NT 10.0)";
assert_eq!(parse_user_agent(ua), "Windows");
}
#[test]
fn unknown_browser_unknown_os() {
assert_eq!(parse_user_agent("totally-bogus-junk"), "Unknown");
}
#[test]
fn does_not_panic_on_pathological_input() {
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");
}
}