use sha2::{Digest, Sha256};
const MAX_USER_AGENT_LEN: usize = 2048;
#[derive(Debug, Clone)]
pub struct DeviceInfo {
pub device_type: String,
pub browser: String,
pub fingerprint: String,
}
impl DeviceInfo {
pub fn from_user_agent(user_agent: Option<&str>) -> Self {
let ua = user_agent.unwrap_or("Unknown");
let ua = if ua.len() > MAX_USER_AGENT_LEN {
&ua[..MAX_USER_AGENT_LEN]
} else {
ua
};
let device_type = parse_device_type(ua);
let browser = parse_browser(ua);
let fingerprint = generate_fingerprint(ua);
Self {
device_type,
browser,
fingerprint,
}
}
}
fn parse_device_type(ua: &str) -> String {
let ua_lower = ua.to_lowercase();
if ua_lower.contains("iphone") {
"iPhone".to_string()
} else if ua_lower.contains("ipad") {
"iPad".to_string()
} else if ua_lower.contains("android") && ua_lower.contains("mobile") {
"Android Phone".to_string()
} else if ua_lower.contains("android") {
"Android Tablet".to_string()
} else if ua_lower.contains("macintosh") || ua_lower.contains("mac os") {
"Mac".to_string()
} else if ua_lower.contains("windows") {
"Windows PC".to_string()
} else if ua_lower.contains("linux") {
"Linux".to_string()
} else if ua_lower.contains("cros") {
"Chromebook".to_string()
} else {
"Unknown device".to_string()
}
}
fn parse_browser(ua: &str) -> String {
if ua.contains("Edg/") || ua.contains("Edge/") {
"Microsoft Edge".to_string()
} else if ua.contains("OPR/") || ua.contains("Opera") {
"Opera".to_string()
} else if ua.contains("Chrome/") && !ua.contains("Chromium/") {
"Chrome".to_string()
} else if ua.contains("Safari/") && !ua.contains("Chrome/") {
"Safari".to_string()
} else if ua.contains("Firefox/") {
"Firefox".to_string()
} else if ua.contains("MSIE") || ua.contains("Trident/") {
"Internet Explorer".to_string()
} else {
"Unknown browser".to_string()
}
}
fn generate_fingerprint(ua: &str) -> String {
let hash = Sha256::digest(ua.as_bytes());
hex::encode(&hash[..16])
}
pub fn is_new_device(current_fingerprint: &str, previous_user_agents: &[Option<String>]) -> bool {
!previous_user_agents
.iter()
.flatten() .any(|prev_ua| generate_fingerprint(prev_ua) == current_fingerprint)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_device_type_iphone() {
let ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)";
assert_eq!(parse_device_type(ua), "iPhone");
}
#[test]
fn test_parse_device_type_mac() {
let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)";
assert_eq!(parse_device_type(ua), "Mac");
}
#[test]
fn test_parse_device_type_windows() {
let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
assert_eq!(parse_device_type(ua), "Windows PC");
}
#[test]
fn test_parse_browser_chrome() {
let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36";
assert_eq!(parse_browser(ua), "Chrome");
}
#[test]
fn test_parse_browser_safari() {
let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15";
assert_eq!(parse_browser(ua), "Safari");
}
#[test]
fn test_parse_browser_firefox() {
let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Firefox/120.0";
assert_eq!(parse_browser(ua), "Firefox");
}
#[test]
fn test_parse_browser_edge() {
let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0";
assert_eq!(parse_browser(ua), "Microsoft Edge");
}
#[test]
fn test_device_info_from_user_agent() {
let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0 Safari/537.36";
let info = DeviceInfo::from_user_agent(Some(ua));
assert_eq!(info.device_type, "Mac");
assert_eq!(info.browser, "Chrome");
assert!(!info.fingerprint.is_empty());
}
#[test]
fn test_is_new_device_true() {
let current = generate_fingerprint("Chrome/120.0 on Mac");
let previous = vec![
Some("Firefox/120.0 on Windows".to_string()),
Some("Safari on iPhone".to_string()),
];
assert!(is_new_device(¤t, &previous));
}
#[test]
fn test_is_new_device_false() {
let ua = "Chrome/120.0 on Mac";
let current = generate_fingerprint(ua);
let previous = vec![
Some("Firefox/120.0 on Windows".to_string()),
Some(ua.to_string()),
];
assert!(!is_new_device(¤t, &previous));
}
#[test]
fn test_is_new_device_empty_history() {
let current = generate_fingerprint("Chrome/120.0 on Mac");
let previous: Vec<Option<String>> = vec![];
assert!(is_new_device(¤t, &previous));
}
#[test]
fn test_oversized_user_agent_truncated() {
let long_ua = "x".repeat(MAX_USER_AGENT_LEN + 1000);
let info = DeviceInfo::from_user_agent(Some(&long_ua));
assert_eq!(info.device_type, "Unknown device");
assert_eq!(info.browser, "Unknown browser");
assert!(!info.fingerprint.is_empty());
let truncated_ua = &long_ua[..MAX_USER_AGENT_LEN];
let expected_fingerprint = generate_fingerprint(truncated_ua);
assert_eq!(info.fingerprint, expected_fingerprint);
}
}