use once_cell::sync::Lazy;
use percent_encoding::percent_decode_str;
use regex::Regex;
fn protocol_regex() -> &'static Regex {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^https?://").unwrap());
&RE
}
fn digit_regex() -> &'static Regex {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\d").unwrap());
&RE
}
fn ip_regex() -> &'static Regex {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d+\.\d+\.\d+\.\d+").unwrap());
&RE
}
pub(crate) fn sensitive_word_regex() -> &'static Regex {
static RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)\b(login|signin|verify|account|password|secure|update|bank|paypal|facebook|google|apple|amazon|ebay|microsoft|yahoo|linkedin)\b").unwrap()
});
&RE
}
fn murmurhash3_x86_32(data: &[u8], seed: u32) -> u32 {
const C1: u32 = 0xcc9e2d51;
const C2: u32 = 0x1b873593;
let mut h1 = seed;
let nblocks = data.len() / 4;
for i in 0..nblocks {
let k1 = u32::from_le_bytes([
data[i * 4],
data[i * 4 + 1],
data[i * 4 + 2],
data[i * 4 + 3],
]);
let mut k1 = k1.wrapping_mul(C1);
k1 = k1.rotate_left(15);
k1 = k1.wrapping_mul(C2);
h1 ^= k1;
h1 = h1.rotate_left(13);
h1 = h1.wrapping_mul(5).wrapping_add(0xe6546b64);
}
let tail = &data[nblocks * 4..];
let mut k1: u32 = 0;
if tail.len() >= 3 {
k1 ^= (tail[2] as u32) << 16;
}
if tail.len() >= 2 {
k1 ^= (tail[1] as u32) << 8;
}
if !tail.is_empty() {
k1 ^= tail[0] as u32;
k1 = k1.wrapping_mul(C1);
k1 = k1.rotate_left(15);
k1 = k1.wrapping_mul(C2);
h1 ^= k1;
}
h1 ^= data.len() as u32;
h1 ^= h1 >> 16;
h1 = h1.wrapping_mul(0x85ebca6b);
h1 ^= h1 >> 13;
h1 = h1.wrapping_mul(0xc2b2ae35);
h1 ^= h1 >> 16;
h1
}
pub fn normalize_url(url: &str) -> String {
let no_fragment = match url.find('#') {
Some(pos) => &url[..pos],
None => url,
};
let decoded = percent_decode_str(no_fragment)
.decode_utf8_lossy()
.to_string();
decode_punycode_domain(&decoded)
}
fn decode_punycode_domain(url: &str) -> String {
let (prefix, rest) = match url.find("://") {
Some(pos) => (&url[..pos + 3], &url[pos + 3..]),
None => ("", url),
};
let (domain, path) = match rest.find('/') {
Some(pos) => (&rest[..pos], &rest[pos..]),
None => (rest, ""),
};
let decoded_domain: String = domain
.split('.')
.map(|label| {
if label.len() >= 4 && label.to_ascii_lowercase().starts_with("xn--") {
idna::punycode::decode_to_string(&label[4..]).unwrap_or_else(|| label.to_string())
} else {
label.to_string()
}
})
.collect::<Vec<_>>()
.join(".");
format!("{}{}{}", prefix, decoded_domain, path)
}
pub fn extract_features(
url: &str,
n_features: usize,
n_manual_features: usize,
ngram_range: [usize; 2],
) -> Vec<f32> {
let normalized = normalize_url(url);
let cleaned = clean_url(&normalized);
let chars: Vec<char> = cleaned.chars().collect();
let mut features = vec![0.0; n_features + n_manual_features];
for n in ngram_range[0]..=ngram_range[1] {
if n == 0 || n > chars.len() {
continue;
}
for i in 0..=(chars.len() - n) {
let gram: String = chars[i..i + n].iter().collect();
let hash = murmurhash3_x86_32(gram.as_bytes(), 0) as usize;
let idx = hash % n_features;
features[idx] += 1.0;
}
}
let manual_features = extract_manual_features(&normalized);
for (i, &val) in manual_features.iter().enumerate() {
if i < n_manual_features {
features[n_features + i] = val;
}
}
features
}
pub(crate) fn extract_struct_features(
normalized: &str,
url_lower: &str,
url_clean: &str,
domain: &str,
path: &str,
) -> Vec<f32> {
let mut feats: Vec<f32> = Vec::with_capacity(18);
if path.is_empty() {
feats.push(0.0);
} else {
let segs = path.split('/').filter(|s| !s.is_empty()).count();
feats.push((segs as f32).min(8.0));
}
let q = if path.contains('?') {
&path[path.find('?').unwrap() + 1..]
} else {
""
};
feats.push((q.matches('=').count() as f32).min(20.0));
let has_port = if domain.contains('/') {
domain[..domain.find('/').unwrap()].contains(':')
} else {
domain.contains(':')
};
feats.push(if has_port { 1.0 } else { 0.0 });
let host_only = if domain.contains('/') {
&domain[..domain.find('/').unwrap()]
} else {
domain
};
let mut port_val = 0i32;
if host_only.contains(':') {
if let Some(after) = host_only.rsplit(':').next() {
port_val = after.parse::<i32>().unwrap_or(0);
}
}
feats.push(((port_val as f32) / 1000.0).min(6.0));
let clean_len = url_clean.chars().count().max(1);
let hexchars = url_clean
.chars()
.filter(|c| c.is_ascii_digit() || ('a'..='f').contains(c))
.count();
feats.push(hexchars as f32 / clean_len as f32);
let alphas = url_clean.chars().filter(|c| c.is_alphabetic()).count();
feats.push(alphas as f32 / clean_len as f32);
let norm_len = normalized.chars().count().max(1);
let ups = normalized.chars().filter(|c| c.is_uppercase()).count();
feats.push(ups as f32 / norm_len as f32);
let labels: Vec<&str> = domain.split('.').collect();
let longest = labels.iter().map(|l| l.chars().count()).max().unwrap_or(0);
feats.push((longest as f32).min(40.0));
feats.push((labels.len() as f32).min(12.0));
if labels.is_empty() {
feats.push(0.0);
} else {
let total: usize = labels.iter().map(|l| l.chars().count()).sum();
feats.push(((total as f32) / (labels.len() as f32)).min(30.0));
}
let dom_len = domain.chars().count().max(1);
let digits_d = domain.chars().filter(|c| c.is_ascii_digit()).count();
feats.push(digits_d as f32 / dom_len as f32);
let dom_entropy = shannon_entropy_bits(domain) / 5.0;
feats.push(dom_entropy.min(1.0));
let tld = labels.last().copied().unwrap_or("");
feats.push((tld.chars().count() as f32).min(20.0));
let path_only = if path.contains('?') {
&path[..path.find('?').unwrap()]
} else {
path
};
let path_len = path_only.chars().count();
let query_len = q.chars().count();
feats.push(((path_len as f32) / ((query_len + 1) as f32)).min(30.0));
let after8: String = url_clean.chars().skip(8).collect();
feats.push(if after8.contains("//") { 1.0 } else { 0.0 });
let trailing = labels.iter().any(|l| l.ends_with('-'));
feats.push(if trailing { 1.0 } else { 0.0 });
let leading = labels
.iter()
.any(|l| !l.is_empty() && l.chars().next().unwrap().is_ascii_digit());
feats.push(if leading { 1.0 } else { 0.0 });
let data_js = url_lower.starts_with("data:") || url_lower.starts_with("javascript:");
feats.push(if data_js { 1.0 } else { 0.0 });
feats.push((q.matches('&').count() as f32).min(20.0));
feats
}
pub fn extract_manual_features(url: &str) -> Vec<f32> {
let normalized = normalize_url(url);
let url_lower = normalized.to_lowercase();
let has_http = if url_lower.starts_with("http://") {
1.0
} else {
0.0
};
let has_https = if url_lower.starts_with("https://") {
1.0
} else {
0.0
};
let url_clean = protocol_regex().replace(&url_lower, "");
let url_clean_str = url_clean.as_ref();
let url_char_count = url_clean_str.chars().count();
let url_len_bucket = if url_char_count < 30 {
1.0
} else if url_char_count < 60 {
2.0
} else if url_char_count < 100 {
3.0
} else if url_char_count < 150 {
4.0
} else {
5.0
};
let (domain, path) = match url_clean_str.find('/') {
Some(pos) => (&url_clean_str[..pos], &url_clean_str[pos..]),
None => (url_clean_str, ""),
};
let domain_char_count = domain.chars().count();
let domain_len_bucket = if domain_char_count < 8 {
1.0
} else if domain_char_count < 15 {
2.0
} else if domain_char_count < 25 {
3.0
} else {
4.0
};
let has_ip = if ip_regex().is_match(domain) {
1.0
} else {
0.0
};
let num_subdomains = domain.chars().filter(|&c| c == '.').count();
let num_subdomains = std::cmp::min(num_subdomains, 5) as f32;
let num_dots = url_clean_str.chars().filter(|&c| c == '.').count() as f32;
let num_hyphens = url_clean_str.chars().filter(|&c| c == '-').count() as f32;
let num_underscores = url_clean_str.chars().filter(|&c| c == '_').count() as f32;
let num_at = url_clean_str.chars().filter(|&c| c == '@').count() as f32;
let num_percent = url_clean_str.chars().filter(|&c| c == '%').count() as f32;
let num_equals = url_clean_str.chars().filter(|&c| c == '=').count() as f32;
let num_qmark = url_clean_str.chars().filter(|&c| c == '?').count() as f32;
let num_and = url_clean_str.chars().filter(|&c| c == '&').count() as f32;
let num_digits = url_clean_str
.chars()
.filter(|&c| c.is_ascii_digit())
.count() as f32;
let digit_ratio = num_digits / url_char_count.max(1) as f32;
let path_char_count = path.chars().count();
let path_len_bucket = if path_char_count == 0 {
1.0
} else if path_char_count < 20 {
2.0
} else if path_char_count < 50 {
3.0
} else {
4.0
};
let query_len = match path.find('?') {
Some(pos) => path.chars().count() - path[..pos].chars().count(),
None => 0,
};
let query_len_bucket = if query_len == 0 {
1.0
} else if query_len < 20 {
2.0
} else if query_len < 50 {
3.0
} else {
4.0
};
let tld = domain.split('.').next_back().unwrap_or("");
let popular_tlds = [
"com", "org", "net", "edu", "gov", "io", "co", "me", "uk", "us", "cn", "jp", "de", "fr",
"au", "ca",
];
let tld_code = popular_tlds
.iter()
.position(|&s| s == tld)
.map_or(0, |i| i + 1) as f32;
let has_sensitive_word = if sensitive_word_regex().is_match(url_clean_str) {
1.0
} else {
0.0
};
let reg = registrable_domain(domain);
let mut brand_score = 0.0f32;
for (_keyword, canonical) in BRAND_CANONICAL {
if reg == *canonical {
continue; }
let sld = canonical.split('.').next().unwrap_or(canonical);
let sld_len = sld.chars().count().max(1) as i32;
let max_dist = 2.max(sld_len / 4) as usize;
let mut best_for_brand = 0.0f32;
let domain_labels: Vec<&str> = domain.split('.').collect();
let cmp: &[&str] = if domain_labels.len() > 1 {
&domain_labels[..domain_labels.len() - 1]
} else {
&domain_labels[..]
};
for label in cmp {
let mut candidates: Vec<&str> = vec![label];
for tok in label.split('-') {
if !tok.is_empty() {
candidates.push(tok);
}
}
for cand in &candidates {
let sim = if *cand == sld {
0.5 } else {
let d = levenshtein(cand, sld) as i32;
let len_diff = (cand.chars().count() as i32 - sld_len).abs();
if d <= max_dist as i32 && len_diff <= 3 {
1.0 - d as f32 / sld_len as f32
} else {
0.0
}
};
if sim > best_for_brand {
best_for_brand = sim;
}
}
}
if best_for_brand > brand_score {
brand_score = best_for_brand;
}
}
brand_score = brand_score.clamp(0.0, 1.0);
let sub_labels: Vec<&str> = domain.split('.').collect();
let reg_labels: Vec<&str> = reg.split('.').collect();
let sub_only: String = if sub_labels.len() > reg_labels.len() {
sub_labels[..sub_labels.len() - reg_labels.len()].join("")
} else {
String::new()
};
let sub_entropy = if sub_only.is_empty() {
0.0
} else {
(shannon_entropy_bits(&sub_only) / 4.0).min(1.0)
};
let mut feats = vec![
has_http,
has_https,
url_len_bucket,
domain_len_bucket,
has_ip,
num_subdomains,
num_dots,
num_hyphens,
num_underscores,
num_at,
num_percent,
num_equals,
num_qmark,
num_and,
digit_ratio,
path_len_bucket,
query_len_bucket,
tld_code,
has_sensitive_word,
brand_score,
sub_entropy,
];
feats.extend(extract_struct_features(
&normalized,
&url_lower,
url_clean_str,
domain,
path,
));
feats
}
pub fn clean_url(url: &str) -> String {
let s = url.to_lowercase();
let s = protocol_regex().replace(&s, "");
let s = digit_regex().replace_all(&s, "0");
s.to_string()
}
pub(crate) const MULTI_PART_TLDS: &[&str] = &[
"co.uk", "org.uk", "ac.uk", "gov.uk", "com.au", "net.au", "org.au", "co.jp", "co.nz", "com.br",
"co.in", "com.cn", "co.kr",
];
pub(crate) const BRAND_CANONICAL: &[(&str, &str)] = &[
("google", "google.com"),
("facebook", "facebook.com"),
("paypal", "paypal.com"),
("amazon", "amazon.com"),
("microsoft", "microsoft.com"),
("apple", "apple.com"),
("ebay", "ebay.com"),
("linkedin", "linkedin.com"),
("netflix", "netflix.com"),
("instagram", "instagram.com"),
("twitter", "twitter.com"),
("whatsapp", "whatsapp.com"),
("yahoo", "yahoo.com"),
("dropbox", "dropbox.com"),
("outlook", "outlook.com"),
("office", "office.com"),
("chase", "chase.com"),
("bankofamerica", "bankofamerica.com"),
("wellsfargo", "wellsfargo.com"),
("citibank", "citibank.com"),
("usbank", "usbank.com"),
("coinbase", "coinbase.com"),
("binance", "binance.com"),
("roblox", "roblox.com"),
("discord", "discord.com"),
("steam", "steamcommunity.com"),
("nvidia", "nvidia.com"),
("tesla", "tesla.com"),
("walmart", "walmart.com"),
("target", "target.com"),
("costco", "costco.com"),
("bestbuy", "bestbuy.com"),
("irs", "irs.gov"),
("americanexpress", "americanexpress.com"),
("visa", "visa.com"),
("mastercard", "mastercard.com"),
];
pub(crate) fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let n = a.len();
let m = b.len();
if n == 0 {
return m;
}
if m == 0 {
return n;
}
let mut prev: Vec<usize> = (0..=m).collect();
let mut cur = vec![0usize; m + 1];
for i in 1..=n {
cur[0] = i;
for j in 1..=m {
let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[m]
}
fn shannon_entropy_bits(s: &str) -> f32 {
if s.is_empty() {
return 0.0;
}
let mut counts: std::collections::HashMap<char, usize> = std::collections::HashMap::new();
for c in s.chars() {
*counts.entry(c).or_insert(0) += 1;
}
let total = s.chars().count() as f32;
let mut h = 0.0f32;
for &c in counts.values() {
let p = c as f32 / total;
h -= p * p.log2();
}
h
}
pub(crate) fn registrable_domain(domain: &str) -> String {
let labels: Vec<&str> = domain.split('.').collect();
if labels.len() <= 2 {
return domain.to_string();
}
let last_two = format!("{}.{}", labels[labels.len() - 2], labels[labels.len() - 1]);
if MULTI_PART_TLDS.contains(&last_two.as_str()) {
return labels[labels.len() - 3..].join(".");
}
last_two
}
pub fn ngrams_for_bucket(
url: &str,
bucket: usize,
n_features: usize,
ngram_range: [usize; 2],
) -> Vec<String> {
let normalized = normalize_url(url);
let cleaned = clean_url(&normalized);
let chars: Vec<char> = cleaned.chars().collect();
let mut result = Vec::new();
for n in ngram_range[0]..=ngram_range[1] {
if n == 0 || n > chars.len() {
continue;
}
for i in 0..=(chars.len() - n) {
let gram: String = chars[i..i + n].iter().collect();
let hash = murmurhash3_x86_32(gram.as_bytes(), 0) as usize;
if hash % n_features == bucket {
result.push(gram);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_url() {
assert_eq!(
clean_url("https://Example123.com/path?query=123"),
"example000.com/path?query=000"
);
assert_eq!(
clean_url("http://192.168.1.1/index.html"),
"000.000.0.0/index.html"
);
assert_eq!(clean_url("example.com"), "example.com");
}
#[test]
fn test_murmurhash3_deterministic() {
let h1 = murmurhash3_x86_32(b"hello", 0);
let h2 = murmurhash3_x86_32(b"hello", 0);
assert_eq!(h1, h2, "Same input must produce same hash");
let h3 = murmurhash3_x86_32(b"world", 0);
assert_ne!(h1, h3, "Different inputs should produce different hashes");
}
#[test]
fn test_murmurhash3_empty() {
let h = murmurhash3_x86_32(b"", 0);
assert_eq!(h, 0, "Empty input with seed 0 should hash to 0");
}
#[test]
fn test_murmurhash3_seed_sensitivity() {
let h1 = murmurhash3_x86_32(b"hello", 0);
let h2 = murmurhash3_x86_32(b"hello", 1);
assert_ne!(h1, h2, "Different seeds should produce different hashes");
}
#[test]
fn test_extract_features_basic() {
let features = extract_features("example.com", 100, 21, [2, 3]);
assert_eq!(features.len(), 121);
let sum: f32 = features.iter().sum();
assert!(sum > 0.0);
}
#[test]
fn test_extract_manual_features() {
let features = extract_manual_features("https://example.com/login");
assert_eq!(features.len(), 40);
assert_eq!(features[0], 0.0);
assert_eq!(features[1], 1.0);
assert_eq!(features[18], 1.0);
assert_eq!(features.len(), 21 + 19);
}
#[test]
fn test_ngrams_for_bucket() {
let grams = ngrams_for_bucket("example.com", 0, 500, [2, 3]);
for g in &grams {
assert!(!g.is_empty());
}
let features = extract_features("example.com", 500, 0, [2, 3]);
for (bucket, &count) in features.iter().enumerate() {
let ngrams = ngrams_for_bucket("example.com", bucket, 500, [2, 3]);
assert_eq!(
count as usize,
ngrams.len(),
"Bucket {} count mismatch",
bucket
);
}
}
#[test]
fn test_ngrams_for_bucket_empty_url() {
let grams = ngrams_for_bucket("", 0, 500, [2, 3]);
assert!(grams.is_empty(), "Empty URL should produce no n-grams");
}
#[test]
fn test_normalize_url_fragment_removal() {
let normalized = normalize_url("https://example.com/login#section");
assert_eq!(normalized, "https://example.com/login");
}
#[test]
fn test_normalize_url_percent_decode() {
let normalized = normalize_url("https://example.com/%70aypal");
assert_eq!(normalized, "https://example.com/paypal");
}
#[test]
fn test_normalize_url_idna_decode() {
let normalized = normalize_url("https://xn--fsq.com/path");
assert!(
normalized.contains("xn--fsq") || normalized != "https://xn--fsq.com/path",
"Punycode domain should be decoded"
);
}
#[test]
fn test_struct_features_consistency() {
let content = include_str!("../resources/test_struct_features.json");
let data: serde_json::Value = serde_json::from_str(content).expect("Failed to parse JSON");
for (url, value) in data.as_object().unwrap() {
let py: Vec<f32> = serde_json::from_value(value.clone()).expect("bad array");
assert_eq!(py.len(), 19, "reference has 19 struct features");
let rust = extract_manual_features(url);
assert_eq!(rust.len(), 40, "manual features must be 40 (21+19)");
let mut max_diff = 0.0f32;
for i in 0..19 {
let d = (rust[21 + i] - py[i]).abs();
if d > max_diff {
max_diff = d;
}
}
assert!(
max_diff < 1e-4,
"struct-feature mismatch for URL '{}' (max diff {:.6})",
url,
max_diff
);
}
}
#[test]
fn test_sensitive_word_word_boundary() {
let features_match = extract_manual_features("https://example.com/login");
assert_eq!(
features_match[18], 1.0,
"'login' at word boundary should match"
);
let features_no_match = extract_manual_features("https://example.com/bloglogin");
assert_eq!(
features_no_match[18], 0.0,
"'login' embedded in 'bloglogin' should NOT match (word boundary)"
);
let features_pineapple = extract_manual_features("https://example.com/pineapple");
assert_eq!(
features_pineapple[18], 0.0,
"'apple' embedded in 'pineapple' should NOT match (word boundary)"
);
}
}