use serde::{Deserialize, Serialize};
use crate::error::Result;
use url::Url;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlAnalysisRequest {
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlAnalysisResult {
pub url: String,
pub scheme: String,
pub hostname: String,
pub tld: Option<String>,
pub subdomain_depth: u32,
pub path_length: usize,
pub query_param_count: u32,
pub is_ip_address: bool,
pub has_non_ascii: bool,
pub risk_signals: Vec<String>,
pub risk_score: u32,
pub error: Option<String>,
}
const SUSPICIOUS_TLDS: &[&str] = &[".xyz", ".tk", ".ml", ".ga", ".cf", ".pw", ".top", ".click"];
pub async fn check_url_analysis(req: &UrlAnalysisRequest) -> Result<UrlAnalysisResult> {
let parsed = match Url::parse(&req.url) {
Ok(u) => u,
Err(e) => {
return Ok(UrlAnalysisResult {
url: req.url.clone(),
scheme: String::new(),
hostname: String::new(),
tld: None,
subdomain_depth: 0,
path_length: 0,
query_param_count: 0,
is_ip_address: false,
has_non_ascii: false,
risk_signals: vec!["invalid_url".to_string()],
risk_score: 0,
error: Some(e.to_string()),
});
}
};
let scheme = parsed.scheme().to_string();
let hostname = parsed.host_str().unwrap_or("").to_string();
let path = parsed.path();
let query = parsed.query().unwrap_or("");
let mut risk_signals = Vec::new();
let mut risk_score: u32 = 0;
let is_ip = hostname.parse::<std::net::IpAddr>().is_ok();
if is_ip {
risk_signals.push("ip_hostname".to_string());
risk_score += 30;
}
let has_non_ascii = !hostname.is_ascii();
if has_non_ascii {
risk_signals.push("non_ascii_hostname".to_string());
risk_score += 25;
}
let subdomain_depth = hostname.matches('.').count() as u32;
if subdomain_depth >= 4 {
risk_signals.push("deep_subdomain".to_string());
risk_score += 20;
}
for tld_str in SUSPICIOUS_TLDS {
if hostname.ends_with(tld_str) {
risk_signals.push("suspicious_tld".to_string());
risk_score += 15;
break;
}
}
if path.len() > 100 {
risk_signals.push("long_path".to_string());
risk_score += 10;
}
let query_param_count = query.split('&').filter(|s| !s.is_empty()).count() as u32;
if query_param_count > 5 {
risk_signals.push("many_params".to_string());
risk_score += 10;
}
let hyphen_count = hostname.matches('-').count();
if hyphen_count >= 4 {
risk_signals.push("excessive_hyphens".to_string());
risk_score += 10;
}
let brand_keywords = ["google", "paypal", "amazon", "apple", "microsoft", "bank", "secure"];
for brand in &brand_keywords {
if hostname.contains(brand) && !is_ip {
risk_signals.push("brand_in_subdomain".to_string());
risk_score += 15;
break;
}
}
let parts: Vec<&str> = hostname.split('.').collect();
let tld = if parts.len() >= 2 {
Some(format!(".{}", parts[parts.len() - 1]))
} else {
None
};
let risk_score = std::cmp::min(risk_score, 100);
Ok(UrlAnalysisResult {
url: req.url.clone(),
scheme,
hostname,
tld,
subdomain_depth,
path_length: path.len(),
query_param_count,
is_ip_address: is_ip,
has_non_ascii,
risk_signals,
risk_score,
error: None,
})
}