use regex::Regex;
use std::sync::OnceLock;
fn protocol_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^https?://").unwrap())
}
fn digit_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\d").unwrap())
}
fn ip_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^\d+\.\d+\.\d+\.\d+").unwrap())
}
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 extract_features(
url: &str,
n_features: usize,
n_manual_features: usize,
ngram_range: [usize; 2],
) -> Vec<f32> {
let cleaned = clean_url(url);
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(url);
for (i, &val) in manual_features.iter().enumerate() {
if i < n_manual_features {
features[n_features + i] = val;
}
}
features
}
pub fn extract_manual_features(url: &str) -> Vec<f32> {
let url_lower = url.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 sensitive_words = [
"login",
"signin",
"verify",
"account",
"password",
"secure",
"update",
"bank",
"paypal",
"facebook",
"google",
"apple",
"amazon",
"ebay",
"microsoft",
"yahoo",
"linkedin",
];
let has_sensitive_word = if sensitive_words.iter().any(|&w| url_clean_str.contains(w)) {
1.0
} else {
0.0
};
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,
]
}
pub fn clean_url(url: &str) -> String {
let s = protocol_regex().replace(url, "");
let s = s.to_lowercase();
let s = digit_regex().replace_all(&s, "0");
s.to_string()
}
pub fn ngrams_for_bucket(
url: &str,
bucket: usize,
n_features: usize,
ngram_range: [usize; 2],
) -> Vec<String> {
let cleaned = clean_url(url);
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, 19, [2, 3]);
assert_eq!(features.len(), 119);
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(), 19);
assert_eq!(features[0], 0.0);
assert_eq!(features[1], 1.0);
assert_eq!(features[18], 1.0);
}
#[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");
}
}