#[cfg(feature = "device-matching")]
use crate::error::FlowGuardError;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::{debug, info, instrument, warn};
use woothee::parser::Parser;
const MAX_USER_AGENT_LENGTH: usize = 2048;
fn sanitize_user_agent(user_agent: &str) -> String {
user_agent
.chars()
.filter(|c| c.is_ascii() || c.is_alphanumeric() || " -./:;()[]{}@".contains(*c))
.collect()
}
#[cfg(feature = "device-matching")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DeviceType {
Mobile,
Desktop,
Tablet,
API,
Unknown,
}
impl DeviceType {
pub fn parse(s: &str) -> Self {
match s.to_lowercase().as_str() {
"mobile" | "smartphone" => DeviceType::Mobile,
"desktop" | "pc" => DeviceType::Desktop,
"tablet" | "ipad" => DeviceType::Tablet,
"api" | "bot" | "crawler" => DeviceType::API,
_ => DeviceType::Unknown,
}
}
pub fn as_str(&self) -> &'static str {
match self {
DeviceType::Mobile => "mobile",
DeviceType::Desktop => "desktop",
DeviceType::Tablet => "tablet",
DeviceType::API => "api",
DeviceType::Unknown => "unknown",
}
}
pub fn is_mobile(&self) -> bool {
matches!(self, DeviceType::Mobile | DeviceType::Tablet)
}
pub fn is_desktop(&self) -> bool {
matches!(self, DeviceType::Desktop)
}
pub fn is_api(&self) -> bool {
matches!(self, DeviceType::API)
}
}
impl std::fmt::Display for DeviceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[cfg(feature = "device-matching")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeviceInfo {
pub device_type: DeviceType,
pub browser: Option<String>,
pub browser_version: Option<String>,
pub os: Option<String>,
pub os_version: Option<String>,
pub user_agent: Option<String>,
}
impl DeviceInfo {
pub fn empty() -> Self {
Self {
device_type: DeviceType::Unknown,
browser: None,
browser_version: None,
os: None,
os_version: None,
user_agent: None,
}
}
pub fn is_empty(&self) -> bool {
self.device_type == DeviceType::Unknown && self.browser.is_none() && self.os.is_none()
}
pub fn description(&self) -> String {
match (&self.device_type, &self.browser, &self.os) {
(DeviceType::API, _, _) => "API Client".to_string(),
(_dt, Some(browser), Some(os)) => {
format!(
"{} {} on {} {}",
browser,
self.browser_version.as_deref().unwrap_or(""),
os,
self.os_version.as_deref().unwrap_or("")
)
}
(dt, Some(browser), None) => {
format!(
"{} {} on {}",
browser,
self.browser_version.as_deref().unwrap_or(""),
dt
)
}
(dt, None, Some(os)) => {
format!("{} on {}", os, dt)
}
(dt, None, None) => dt.to_string(),
}
}
fn from_woothee(result: &woothee::parser::WootheeResult) -> Self {
let device_type = Self::map_woothee_device_type(result.category);
let browser = if device_type != DeviceType::API {
Some(result.name.to_string())
} else {
None
};
let browser_version = if device_type != DeviceType::API {
Some(result.version.to_string())
} else {
None
};
let os = if device_type != DeviceType::API {
Some(result.os.to_string())
} else {
None
};
let os_version = if device_type != DeviceType::API {
Some(result.os_version.to_string())
} else {
None
};
Self {
device_type,
browser,
browser_version,
os,
os_version,
user_agent: None,
}
}
fn map_woothee_device_type(category: &str) -> DeviceType {
match category.to_lowercase().as_str() {
"pc" => DeviceType::Desktop,
"smartphone" => DeviceType::Mobile,
"mobilephone" => DeviceType::Mobile,
"tablet" => DeviceType::Tablet,
"appliance" => DeviceType::API,
"crawler" => DeviceType::API,
"misc" => DeviceType::API,
_ => DeviceType::Unknown,
}
}
}
impl Default for DeviceInfo {
fn default() -> Self {
Self::empty()
}
}
#[cfg(feature = "device-matching")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeviceCondition {
pub device_types: Vec<DeviceType>,
pub browsers: Vec<String>,
pub os: Vec<String>,
}
impl DeviceCondition {
pub fn empty() -> Self {
Self {
device_types: vec![],
browsers: vec![],
os: vec![],
}
}
pub fn device_types(device_types: Vec<DeviceType>) -> Self {
Self {
device_types,
browsers: vec![],
os: vec![],
}
}
pub fn browsers(browsers: Vec<String>) -> Self {
Self {
device_types: vec![],
browsers,
os: vec![],
}
}
pub fn os(os: Vec<String>) -> Self {
Self {
device_types: vec![],
browsers: vec![],
os,
}
}
pub fn is_empty(&self) -> bool {
self.device_types.is_empty() && self.browsers.is_empty() && self.os.is_empty()
}
pub fn matches(&self, info: &DeviceInfo) -> bool {
if self.is_empty() {
return true;
}
if !self.device_types.is_empty() {
if self.device_types.contains(&info.device_type) {
return true;
}
return false;
}
if !self.browsers.is_empty() {
if let Some(browser) = &info.browser {
if self.browsers.iter().any(|b| {
browser.to_lowercase().contains(&b.to_lowercase())
|| b.to_lowercase().contains(&browser.to_lowercase())
}) {
return true;
}
}
return false;
}
if !self.os.is_empty() {
if let Some(os) = &info.os {
if self.os.iter().any(|o| {
os.to_lowercase().contains(&o.to_lowercase())
|| o.to_lowercase().contains(&os.to_lowercase())
}) {
return true;
}
}
return false;
}
false
}
}
impl Default for DeviceCondition {
fn default() -> Self {
Self::empty()
}
}
#[cfg(feature = "device-matching")]
pub struct DeviceMatcher {
parser: Arc<Parser>,
cache: Arc<DashMap<String, DeviceInfo>>,
cache_size_limit: usize,
custom_rules: Vec<DeviceCustomRule>,
}
#[derive(Debug, Clone)]
struct DeviceCustomRule {
name: String,
pattern: String,
device_type: DeviceType,
browser: Option<String>,
os: Option<String>,
}
impl DeviceMatcher {
#[instrument]
pub async fn new() -> Result<Self, FlowGuardError> {
info!("创建DeviceMatcher");
let parser = Parser::new();
let matcher = Self {
parser: Arc::new(parser),
cache: Arc::new(DashMap::new()),
cache_size_limit: 10_000,
custom_rules: Self::default_custom_rules(),
};
info!("DeviceMatcher创建成功");
Ok(matcher)
}
#[instrument]
pub async fn with_cache_limit(cache_size_limit: usize) -> Result<Self, FlowGuardError> {
let mut matcher = Self::new().await?;
matcher.cache_size_limit = cache_size_limit;
Ok(matcher)
}
#[instrument(skip(self))]
pub fn parse(&self, user_agent: &str) -> Result<DeviceInfo, FlowGuardError> {
let sanitized = sanitize_user_agent(user_agent);
let user_agent = sanitized.trim();
if user_agent.is_empty() {
return Ok(DeviceInfo::empty());
}
if user_agent.len() > MAX_USER_AGENT_LENGTH {
return Err(FlowGuardError::ConfigError(format!(
"User-Agent 长度超过限制(最大 {} 字符)",
MAX_USER_AGENT_LENGTH
)));
}
if let Some(cached) = self.cache.get(user_agent) {
debug!("缓存命中: {}", user_agent);
return Ok(cached.clone());
}
debug!("解析User-Agent: {}", user_agent);
for rule in &self.custom_rules {
if let Ok(re) = regex::Regex::new(&rule.pattern) {
if re.is_match(user_agent) {
let info = DeviceInfo {
device_type: rule.device_type,
browser: rule.browser.clone(),
browser_version: None,
os: rule.os.clone(),
os_version: None,
user_agent: Some(user_agent.to_string()),
};
self.update_cache(user_agent, &info);
debug!("自定义规则匹配: {}", rule.name);
return Ok(info);
}
}
}
let result = self.parser.parse(user_agent);
let mut info = if let Some(res) = result {
DeviceInfo::from_woothee(&res)
} else {
DeviceInfo::empty()
};
info.user_agent = Some(user_agent.to_string());
self.update_cache(user_agent, &info);
debug!(
"User-Agent解析成功: {} -> {}",
user_agent,
info.description()
);
Ok(info)
}
#[instrument(skip(self, user_agents))]
pub fn batch_parse(&self, user_agents: &[String]) -> Vec<Result<DeviceInfo, FlowGuardError>> {
user_agents.iter().map(|ua| self.parse(ua)).collect()
}
#[instrument(skip(self, condition))]
pub fn matches_user_agent(
&self,
user_agent: &str,
condition: &DeviceCondition,
) -> Result<bool, FlowGuardError> {
let info = self.parse(user_agent)?;
Ok(condition.matches(&info))
}
pub fn matches(&self, info: &DeviceInfo, condition: &DeviceCondition) -> bool {
condition.matches(info)
}
pub fn add_custom_rule(
&mut self,
name: &str,
pattern: &str,
device_type: DeviceType,
browser: Option<String>,
os: Option<String>,
) {
let rule = DeviceCustomRule {
name: name.to_string(),
pattern: pattern.to_string(),
device_type,
browser,
os,
};
if regex::Regex::new(&rule.pattern).is_err() {
warn!("无效的正则表达式: {}", pattern);
return;
}
self.custom_rules.push(rule);
info!("添加自定义规则: {}", name);
}
pub fn remove_custom_rule(&mut self, name: &str) -> bool {
let original_len = self.custom_rules.len();
self.custom_rules.retain(|r| r.name != name);
let removed = self.custom_rules.len() < original_len;
if removed {
info!("移除自定义规则: {}", name);
}
removed
}
#[instrument(skip(self))]
pub fn clear_cache(&self) {
let size = self.cache.len();
self.cache.clear();
info!("缓存已清空,移除 {} 条记录", size);
}
pub fn cache_stats(&self) -> DeviceCacheStats {
DeviceCacheStats {
size: self.cache.len(),
limit: self.cache_size_limit,
hit_rate: 0.0, }
}
fn update_cache(&self, user_agent: &str, info: &DeviceInfo) {
if self.cache.len() >= self.cache_size_limit {
let remove_count = self.cache_size_limit / 10;
let keys_to_remove: Vec<_> = self
.cache
.iter()
.take(remove_count)
.map(|k| k.key().clone())
.collect();
for key in keys_to_remove {
self.cache.remove(&key);
}
debug!("缓存清理完成,移除 {} 条记录", remove_count);
}
self.cache.insert(user_agent.to_string(), info.clone());
}
fn default_custom_rules() -> Vec<DeviceCustomRule> {
vec![
DeviceCustomRule {
name: "curl".to_string(),
pattern: r"^curl/".to_string(),
device_type: DeviceType::API,
browser: Some("curl".to_string()),
os: None,
},
DeviceCustomRule {
name: "wget".to_string(),
pattern: r"^Wget/".to_string(),
device_type: DeviceType::API,
browser: Some("wget".to_string()),
os: None,
},
DeviceCustomRule {
name: "googlebot".to_string(),
pattern: r"Googlebot".to_string(),
device_type: DeviceType::API,
browser: Some("Googlebot".to_string()),
os: None,
},
DeviceCustomRule {
name: "bingbot".to_string(),
pattern: r"Bingbot".to_string(),
device_type: DeviceType::API,
browser: Some("Bingbot".to_string()),
os: None,
},
]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCacheStats {
pub size: usize,
pub limit: usize,
pub hit_rate: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_device_type_from_str() {
assert_eq!(DeviceType::parse("mobile"), DeviceType::Mobile);
assert_eq!(DeviceType::parse("desktop"), DeviceType::Desktop);
assert_eq!(DeviceType::parse("tablet"), DeviceType::Tablet);
assert_eq!(DeviceType::parse("api"), DeviceType::API);
assert_eq!(DeviceType::parse("unknown"), DeviceType::Unknown);
assert_eq!(DeviceType::parse("invalid"), DeviceType::Unknown);
}
#[test]
fn test_device_type_as_str() {
assert_eq!(DeviceType::Mobile.as_str(), "mobile");
assert_eq!(DeviceType::Desktop.as_str(), "desktop");
assert_eq!(DeviceType::Tablet.as_str(), "tablet");
assert_eq!(DeviceType::API.as_str(), "api");
assert_eq!(DeviceType::Unknown.as_str(), "unknown");
}
#[test]
fn test_device_type_is_mobile() {
assert!(DeviceType::Mobile.is_mobile());
assert!(DeviceType::Tablet.is_mobile());
assert!(!DeviceType::Desktop.is_mobile());
assert!(!DeviceType::API.is_mobile());
}
#[test]
fn test_device_info_empty() {
let info = DeviceInfo::empty();
assert!(info.is_empty());
assert_eq!(info.description(), "unknown");
}
#[test]
fn test_device_info_description() {
let info1 = DeviceInfo {
device_type: DeviceType::Mobile,
browser: Some("Safari".to_string()),
browser_version: Some("14.0".to_string()),
os: Some("iOS".to_string()),
os_version: Some("14.0".to_string()),
user_agent: None,
};
assert!(info1.description().contains("Safari"));
assert!(info1.description().contains("iOS"));
let info2 = DeviceInfo {
device_type: DeviceType::API,
browser: None,
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert_eq!(info2.description(), "API Client");
}
#[test]
fn test_device_condition_empty() {
let condition = DeviceCondition::empty();
assert!(condition.is_empty());
let info = DeviceInfo::empty();
assert!(condition.matches(&info));
}
#[test]
fn test_device_condition_device_types() {
let condition = DeviceCondition::device_types(vec![DeviceType::Mobile]);
let info1 = DeviceInfo {
device_type: DeviceType::Mobile,
browser: None,
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert!(condition.matches(&info1));
let info2 = DeviceInfo {
device_type: DeviceType::Desktop,
browser: None,
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert!(!condition.matches(&info2));
}
#[test]
fn test_device_condition_browsers() {
let condition = DeviceCondition::browsers(vec!["Safari".to_string(), "Chrome".to_string()]);
let info1 = DeviceInfo {
device_type: DeviceType::Mobile,
browser: Some("Safari".to_string()),
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert!(condition.matches(&info1));
let info2 = DeviceInfo {
device_type: DeviceType::Mobile,
browser: Some("Firefox".to_string()),
browser_version: None,
os: None,
os_version: None,
user_agent: None,
};
assert!(!condition.matches(&info2));
}
#[test]
fn test_device_condition_os() {
let condition = DeviceCondition::os(vec!["iOS".to_string(), "Android".to_string()]);
let info1 = DeviceInfo {
device_type: DeviceType::Mobile,
browser: None,
browser_version: None,
os: Some("iOS".to_string()),
os_version: None,
user_agent: None,
};
assert!(condition.matches(&info1));
let info2 = DeviceInfo {
device_type: DeviceType::Desktop,
browser: None,
browser_version: None,
os: Some("Windows".to_string()),
os_version: None,
user_agent: None,
};
assert!(!condition.matches(&info2));
}
#[test]
fn test_device_condition_default() {
let condition = DeviceCondition::default();
assert!(condition.is_empty());
}
#[test]
fn test_device_info_serialization() {
let info = DeviceInfo {
device_type: DeviceType::Mobile,
browser: Some("Safari".to_string()),
browser_version: Some("14.0".to_string()),
os: Some("iOS".to_string()),
os_version: Some("14.0".to_string()),
user_agent: Some("Test".to_string()),
};
let json = serde_json::to_string(&info).unwrap();
let deserialized: DeviceInfo = serde_json::from_str(&json).unwrap();
assert_eq!(info, deserialized);
}
#[test]
fn test_device_condition_serialization() {
let condition = DeviceCondition {
device_types: vec![DeviceType::Mobile, DeviceType::Tablet],
browsers: vec!["Safari".to_string()],
os: vec!["iOS".to_string()],
};
let json = serde_json::to_string(&condition).unwrap();
let deserialized: DeviceCondition = serde_json::from_str(&json).unwrap();
assert_eq!(condition, deserialized);
}
#[tokio::test]
async fn test_device_matcher_parse_empty() {
let matcher = DeviceMatcher::new().await.unwrap();
let info = matcher.parse("").unwrap();
assert!(info.is_empty());
let info = matcher.parse(" ").unwrap();
assert!(info.is_empty());
}
#[tokio::test]
async fn test_device_matcher_parse_iphone() {
let matcher = DeviceMatcher::new().await.unwrap();
let user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) \
AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 \
Safari/604.1";
let info = matcher.parse(user_agent).unwrap();
assert_eq!(info.device_type, DeviceType::Mobile);
assert!(info.browser.as_ref().unwrap().contains("Safari"));
assert!(info.os.is_some());
}
#[tokio::test]
async fn test_device_matcher_parse_desktop() {
let matcher = DeviceMatcher::new().await.unwrap();
let user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, \
like Gecko) Chrome/91.0.4472.124 Safari/537.36";
let info = matcher.parse(user_agent).unwrap();
assert_eq!(info.device_type, DeviceType::Desktop);
assert!(info.browser.as_ref().unwrap().contains("Chrome"));
assert!(info.os.as_ref().unwrap().contains("Windows"));
}
#[tokio::test]
async fn test_device_matcher_parse_curl() {
let matcher = DeviceMatcher::new().await.unwrap();
let user_agent = "curl/7.68.0";
let info = matcher.parse(user_agent).unwrap();
assert_eq!(info.device_type, DeviceType::API);
assert_eq!(info.browser, Some("curl".to_string()));
}
#[tokio::test]
async fn test_device_matcher_cache() {
let matcher = DeviceMatcher::new().await.unwrap();
let user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)";
let info1 = matcher.parse(user_agent).unwrap();
let stats1 = matcher.cache_stats();
assert_eq!(stats1.size, 1);
let info2 = matcher.parse(user_agent).unwrap();
assert_eq!(info1, info2);
matcher.clear_cache();
let stats2 = matcher.cache_stats();
assert_eq!(stats2.size, 0);
}
#[tokio::test]
async fn test_device_matcher_custom_rule() {
let mut matcher = DeviceMatcher::new().await.unwrap();
matcher.add_custom_rule(
"TestApp",
r"TestApp/\d+\.\d+",
DeviceType::Mobile,
Some("TestApp".to_string()),
Some("Android".to_string()),
);
let user_agent = "TestApp/1.0.0";
let info = matcher.parse(user_agent).unwrap();
assert_eq!(info.device_type, DeviceType::Mobile);
assert_eq!(info.browser, Some("TestApp".to_string()));
}
#[tokio::test]
async fn test_device_matcher_remove_custom_rule() {
let mut matcher = DeviceMatcher::new().await.unwrap();
matcher.add_custom_rule(
"TestApp",
r"TestApp/\d+\.\d+",
DeviceType::Mobile,
Some("TestApp".to_string()),
Some("Android".to_string()),
);
assert!(matcher.remove_custom_rule("TestApp"));
assert!(!matcher.remove_custom_rule("NonExistent"));
}
#[tokio::test]
async fn test_device_matcher_batch_parse() {
let matcher = DeviceMatcher::new().await.unwrap();
let user_agents = vec![
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)".to_string(),
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36".to_string(),
"curl/7.68.0".to_string(),
];
let results = matcher.batch_parse(&user_agents);
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| r.is_ok()));
assert_eq!(results[0].as_ref().unwrap().device_type, DeviceType::Mobile);
assert_eq!(
results[1].as_ref().unwrap().device_type,
DeviceType::Desktop
);
assert_eq!(results[2].as_ref().unwrap().device_type, DeviceType::API);
}
#[tokio::test]
async fn test_device_matcher_matches_user_agent() {
let matcher = DeviceMatcher::new().await.unwrap();
let condition = DeviceCondition::device_types(vec![DeviceType::Mobile]);
let user_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)";
let matched = matcher.matches_user_agent(user_agent, &condition).unwrap();
assert!(matched);
let user_agent2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
let matched2 = matcher.matches_user_agent(user_agent2, &condition).unwrap();
assert!(!matched2);
}
}