use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use url::Url;
const BASELINE_RAW: &str = include_str!("baseline.txt");
#[derive(Debug, Clone, Default)]
pub struct BlockList {
domains: HashSet<String>,
}
impl BlockList {
pub fn empty() -> Self {
Self::default()
}
pub fn baseline() -> Self {
let mut s = Self::default();
s.extend_from_str(BASELINE_RAW);
s
}
pub fn baseline_with_override(override_path: &Path) -> Self {
let mut s = Self::baseline();
if let Ok(body) = std::fs::read_to_string(override_path) {
s.extend_from_str(&body);
}
s
}
pub fn extend_from_str(&mut self, body: &str) {
for line in body.lines() {
if let Some(d) = parse_line(line) {
self.domains.insert(d);
}
}
}
pub fn len(&self) -> usize {
self.domains.len()
}
pub fn contains_exact(&self, host: &str) -> bool {
self.domains.contains(host)
}
pub fn matches_host(&self, host: &str) -> bool {
let host = host.trim_end_matches('.').to_ascii_lowercase();
if host.is_empty() {
return false;
}
if self.domains.contains(&host) {
return true;
}
let mut rest = host.as_str();
while let Some(idx) = rest.find('.') {
rest = &rest[idx + 1..];
if rest.is_empty() {
break;
}
if self.domains.contains(rest) {
return true;
}
}
false
}
pub fn matches_url(&self, url: &str) -> bool {
match Url::parse(url) {
Ok(u) => u.host_str().map(|h| self.matches_host(h)).unwrap_or(false),
Err(_) => false,
}
}
}
pub fn extract_domain(line: &str) -> Option<String> {
parse_line(line)
}
fn parse_line(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with('!') || trimmed.starts_with('[') {
return None;
}
if trimmed.contains("##") || trimmed.contains("#@#") || trimmed.contains("#?#") {
return None;
}
let trimmed = trimmed.split('#').next().unwrap_or("").trim();
if trimmed.is_empty() {
return None;
}
let candidate = if let Some(rest) = trimmed.strip_prefix("||") {
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_'))
.unwrap_or(rest.len());
&rest[..end]
} else if trimmed.starts_with("0.0.0.0 ") || trimmed.starts_with("127.0.0.1 ") {
trimmed.split_whitespace().nth(1).unwrap_or("")
} else {
let domain_end = trimmed
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_'))
.unwrap_or(trimmed.len());
&trimmed[..domain_end]
};
let host = candidate.trim_matches('.').to_ascii_lowercase();
if host.is_empty() || !host.contains('.') {
return None;
}
Some(host)
}
pub fn default_override_path() -> PathBuf {
if let Ok(p) = std::env::var("CRAWLEX_BLOCKLIST") {
return PathBuf::from(p);
}
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".config"))
})
.unwrap_or_else(|| PathBuf::from("."));
base.join("crawlex").join("blocklist.txt")
}
pub fn global() -> &'static BlockList {
static GLOBAL: OnceLock<BlockList> = OnceLock::new();
GLOBAL.get_or_init(|| BlockList::baseline_with_override(&default_override_path()))
}
pub fn is_blocked(url: &str) -> bool {
global().matches_url(url)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn baseline_parses_nonempty() {
let list = BlockList::baseline();
assert!(
list.len() > 100,
"baseline should contain hundreds of entries, got {}",
list.len()
);
assert!(list.contains_exact("doubleclick.net"));
assert!(list.contains_exact("google-analytics.com"));
}
#[test]
fn matches_exact_host() {
let list = BlockList::baseline();
assert!(list.matches_host("doubleclick.net"));
}
#[test]
fn matches_subdomain_via_suffix() {
let list = BlockList::baseline();
assert!(list.matches_host("pagead.l.doubleclick.net"));
assert!(list.matches_host("foo.bar.baz.doubleclick.net"));
}
#[test]
fn does_not_match_unrelated_host() {
let list = BlockList::baseline();
assert!(!list.matches_host("example.com"));
assert!(!list.matches_host("rust-lang.org"));
}
#[test]
fn matches_url_strips_scheme_and_path() {
let list = BlockList::baseline();
assert!(list.matches_url("https://pagead.l.doubleclick.net/some/path?x=1"));
assert!(!list.matches_url("https://example.com/"));
}
#[test]
fn override_unions_with_baseline() {
let mut list = BlockList::baseline();
list.extend_from_str("# override\nads.private.example.test\n");
assert!(list.matches_host("ads.private.example.test"));
assert!(list.matches_host("foo.ads.private.example.test"));
assert!(list.matches_host("doubleclick.net"));
}
#[test]
fn parses_easylist_domain_rules() {
let mut list = BlockList::empty();
list.extend_from_str(
r#"
[Adblock Plus 2.0]
! Title: Test list
||evil-tracker.test^
||third.party.test^$third-party
example.test##.banner
||bad.test$image
"#,
);
assert!(list.matches_host("evil-tracker.test"));
assert!(list.matches_host("third.party.test"));
assert!(list.matches_host("bad.test"));
assert!(!list.matches_host("example.test"));
}
#[test]
fn parses_hosts_file_format() {
let mut list = BlockList::empty();
list.extend_from_str("0.0.0.0 evil.test\n127.0.0.1 other.test\n");
assert!(list.matches_host("evil.test"));
assert!(list.matches_host("other.test"));
}
#[test]
fn rejects_single_label_entries() {
let mut list = BlockList::empty();
list.extend_from_str("com\norg\n");
assert!(!list.matches_host("example.com"));
assert!(!list.matches_host("rust-lang.org"));
}
#[test]
fn override_round_trip_via_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("blocklist.txt");
std::fs::write(&path, "||rt-override.test^\n").unwrap();
let list = BlockList::baseline_with_override(&path);
assert!(list.matches_host("rt-override.test"));
assert!(list.matches_host("x.y.rt-override.test"));
assert!(list.matches_host("doubleclick.net"));
}
#[test]
fn case_and_trailing_dot_normalised() {
let list = BlockList::baseline();
assert!(list.matches_host("DoubleClick.NET"));
assert!(list.matches_host("doubleclick.net."));
}
}