From d66a88f4674986203cfebb22f0a884b54d0aa6cd Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 21:15:15 +0300 Subject: [PATCH 1/5] fix: allowlist parent domain unblocks subdomains in blocklist The allowlist walk-up was interleaved with the blocklist walk-up, so an exact blocklist match on www.example.com short-circuited before reaching example.com in the allowlist. Now allowlist is checked at all parent levels before consulting the blocklist. Deduplicate is_blocked/check via find_in_set helper; is_blocked delegates to check. Adds 7 new blocklist tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/blocklist.rs | 142 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 97 insertions(+), 45 deletions(-) diff --git a/src/blocklist.rs b/src/blocklist.rs index e5caa99..7f10497 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -78,40 +78,9 @@ impl BlocklistStore { } pub fn is_blocked(&self, domain: &str) -> bool { - if !self.enabled { - return false; - } - - if let Some(until) = self.paused_until { - if Instant::now() < until { - return false; - } - } - - if self.allowlist.contains(domain) { - return false; - } - - if self.domains.contains(domain) { - return true; - } - - // Walk up: ads.tracker.example.com → tracker.example.com → example.com - let mut d = domain; - while let Some(dot) = d.find('.') { - d = &d[dot + 1..]; - if self.allowlist.contains(d) { - return false; - } - if self.domains.contains(d) { - return true; - } - } - - false + self.check(domain).blocked } - /// Check if a domain is blocked and return the reason. pub fn check(&self, domain: &str) -> BlockCheckResult { let domain = domain.to_lowercase(); @@ -119,28 +88,39 @@ impl BlocklistStore { return BlockCheckResult::disabled(); } - if self.allowlist.contains(&domain) { - return BlockCheckResult::allowed(&domain, "exact match in allowlist"); + if let Some(until) = self.paused_until { + if Instant::now() < until { + return BlockCheckResult::disabled(); + } } - if self.domains.contains(&domain) { - return BlockCheckResult::blocked(&domain, "exact match in blocklist"); + if let Some(matched) = Self::find_in_set(&domain, &self.allowlist) { + let reason = if matched == domain { "exact match in allowlist" } else { "parent domain in allowlist" }; + return BlockCheckResult::allowed(matched, reason); } - let mut d = domain.as_str(); - while let Some(dot) = d.find('.') { - d = &d[dot + 1..]; - if self.allowlist.contains(d) { - return BlockCheckResult::allowed(d, "parent domain in allowlist"); - } - if self.domains.contains(d) { - return BlockCheckResult::blocked(d, "parent domain in blocklist"); - } + if let Some(matched) = Self::find_in_set(&domain, &self.domains) { + let reason = if matched == domain { "exact match in blocklist" } else { "parent domain in blocklist" }; + return BlockCheckResult::blocked(matched, reason); } BlockCheckResult::not_blocked() } + fn find_in_set<'a>(domain: &'a str, set: &HashSet) -> Option<&'a str> { + if set.contains(domain) { + return Some(domain); + } + let mut d = domain; + while let Some(dot) = d.find('.') { + d = &d[dot + 1..]; + if set.contains(d) { + return Some(d); + } + } + None + } + /// Atomically swap in a new domain set. Build the set outside the lock, /// then call this to swap — keeps lock hold time sub-microsecond. pub fn swap_domains(&mut self, domains: HashSet, sources: Vec) { @@ -247,6 +227,78 @@ pub fn parse_blocklist(text: &str) -> HashSet { mod tests { use super::*; + fn store_with(domains: &[&str], allowlist: &[&str]) -> BlocklistStore { + let mut store = BlocklistStore::new(); + store.swap_domains( + domains.iter().map(|s| s.to_string()).collect(), + vec![], + ); + for d in allowlist { + store.add_to_allowlist(d); + } + store + } + + #[test] + fn exact_block() { + let store = store_with(&["ads.example.com"], &[]); + assert!(store.is_blocked("ads.example.com")); + assert!(!store.is_blocked("example.com")); + } + + #[test] + fn parent_block_covers_subdomain() { + let store = store_with(&["tracker.com"], &[]); + assert!(store.is_blocked("tracker.com")); + assert!(store.is_blocked("www.tracker.com")); + assert!(store.is_blocked("deep.sub.tracker.com")); + } + + #[test] + fn exact_allowlist_unblocks() { + let store = store_with(&["ads.example.com"], &["ads.example.com"]); + assert!(!store.is_blocked("ads.example.com")); + } + + #[test] + fn parent_allowlist_unblocks_subdomain() { + let store = store_with( + &["example.com", "www.example.com"], + &["example.com"], + ); + assert!(!store.is_blocked("example.com")); + assert!(!store.is_blocked("www.example.com")); + assert!(!store.is_blocked("sub.deep.example.com")); + } + + #[test] + fn allowlist_does_not_unblock_sibling() { + let store = store_with( + &["www.example.com", "ads.example.com"], + &["www.example.com"], + ); + assert!(!store.is_blocked("www.example.com")); + assert!(store.is_blocked("ads.example.com")); + } + + #[test] + fn check_reports_parent_allowlist() { + let store = store_with( + &["goatcounter.com", "www.goatcounter.com"], + &["goatcounter.com"], + ); + let result = store.check("www.goatcounter.com"); + assert!(!result.blocked); + assert_eq!(result.matched_rule.as_deref(), Some("goatcounter.com")); + } + + #[test] + fn disabled_never_blocks() { + let mut store = store_with(&["ads.example.com"], &[]); + store.set_enabled(false); + assert!(!store.is_blocked("ads.example.com")); + } + #[test] fn heap_bytes_grows_with_domains() { let mut store = BlocklistStore::new(); -- 2.34.1 From c452f99a45225566a72342cdd6733145a178b761 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 21:15:24 +0300 Subject: [PATCH 2/5] style: rustfmt blocklist tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/blocklist.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/blocklist.rs b/src/blocklist.rs index 7f10497..8ae2c76 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -95,12 +95,20 @@ impl BlocklistStore { } if let Some(matched) = Self::find_in_set(&domain, &self.allowlist) { - let reason = if matched == domain { "exact match in allowlist" } else { "parent domain in allowlist" }; + let reason = if matched == domain { + "exact match in allowlist" + } else { + "parent domain in allowlist" + }; return BlockCheckResult::allowed(matched, reason); } if let Some(matched) = Self::find_in_set(&domain, &self.domains) { - let reason = if matched == domain { "exact match in blocklist" } else { "parent domain in blocklist" }; + let reason = if matched == domain { + "exact match in blocklist" + } else { + "parent domain in blocklist" + }; return BlockCheckResult::blocked(matched, reason); } @@ -229,10 +237,7 @@ mod tests { fn store_with(domains: &[&str], allowlist: &[&str]) -> BlocklistStore { let mut store = BlocklistStore::new(); - store.swap_domains( - domains.iter().map(|s| s.to_string()).collect(), - vec![], - ); + store.swap_domains(domains.iter().map(|s| s.to_string()).collect(), vec![]); for d in allowlist { store.add_to_allowlist(d); } @@ -262,10 +267,7 @@ mod tests { #[test] fn parent_allowlist_unblocks_subdomain() { - let store = store_with( - &["example.com", "www.example.com"], - &["example.com"], - ); + let store = store_with(&["example.com", "www.example.com"], &["example.com"]); assert!(!store.is_blocked("example.com")); assert!(!store.is_blocked("www.example.com")); assert!(!store.is_blocked("sub.deep.example.com")); -- 2.34.1 From ec44829c309f83bc83bfbe4d5ba7b479853304be Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 21:33:26 +0300 Subject: [PATCH 3/5] perf: zero-alloc is_blocked hot path, normalize trailing dots Co-Authored-By: Claude Opus 4.6 --- src/blocklist.rs | 56 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/src/blocklist.rs b/src/blocklist.rs index 8ae2c76..dbbbea6 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -78,12 +78,23 @@ impl BlocklistStore { } pub fn is_blocked(&self, domain: &str) -> bool { - self.check(domain).blocked + if !self.enabled { + return false; + } + if let Some(until) = self.paused_until { + if Instant::now() < until { + return false; + } + } + let domain = domain.to_lowercase(); + let domain = domain.trim_end_matches('.'); + if Self::find_in_set(domain, &self.allowlist).is_some() { + return false; + } + Self::find_in_set(domain, &self.domains).is_some() } pub fn check(&self, domain: &str) -> BlockCheckResult { - let domain = domain.to_lowercase(); - if !self.enabled { return BlockCheckResult::disabled(); } @@ -94,7 +105,10 @@ impl BlocklistStore { } } - if let Some(matched) = Self::find_in_set(&domain, &self.allowlist) { + let domain = domain.to_lowercase(); + let domain = domain.trim_end_matches('.'); + + if let Some(matched) = Self::find_in_set(domain, &self.allowlist) { let reason = if matched == domain { "exact match in allowlist" } else { @@ -103,7 +117,7 @@ impl BlocklistStore { return BlockCheckResult::allowed(matched, reason); } - if let Some(matched) = Self::find_in_set(&domain, &self.domains) { + if let Some(matched) = Self::find_in_set(domain, &self.domains) { let reason = if matched == domain { "exact match in blocklist" } else { @@ -160,11 +174,14 @@ impl BlocklistStore { } pub fn add_to_allowlist(&mut self, domain: &str) { - self.allowlist.insert(domain.to_lowercase()); + let d = domain.to_lowercase(); + self.allowlist + .insert(d.trim_end_matches('.').to_string()); } pub fn remove_from_allowlist(&mut self, domain: &str) -> bool { - self.allowlist.remove(&domain.to_lowercase()) + let d = domain.to_lowercase(); + self.allowlist.remove(d.trim_end_matches('.')) } pub fn allowlist(&self) -> Vec { @@ -301,6 +318,31 @@ mod tests { assert!(!store.is_blocked("ads.example.com")); } + #[test] + fn trailing_dot_normalized() { + let store = store_with(&["ads.example.com"], &["safe.example.com"]); + assert!(store.is_blocked("ads.example.com.")); + assert!(!store.is_blocked("safe.example.com.")); + let result = store.check("ads.example.com."); + assert!(result.blocked); + } + + #[test] + fn case_insensitive() { + let store = store_with(&["ads.example.com"], &["safe.example.com"]); + assert!(store.is_blocked("ADS.Example.COM")); + assert!(!store.is_blocked("Safe.Example.COM")); + } + + #[test] + fn domain_in_neither_list() { + let store = store_with(&["ads.example.com"], &[]); + let result = store.check("clean.example.org"); + assert!(!result.blocked); + assert_eq!(result.reason, "not in blocklist"); + assert!(result.matched_rule.is_none()); + } + #[test] fn heap_bytes_grows_with_domains() { let mut store = BlocklistStore::new(); -- 2.34.1 From e5c6caba1fde6eb03e11d89d631a8e87c006534b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 21:33:38 +0300 Subject: [PATCH 4/5] style: rustfmt add_to_allowlist Co-Authored-By: Claude Opus 4.6 --- src/blocklist.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/blocklist.rs b/src/blocklist.rs index dbbbea6..8ba2fe7 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -175,8 +175,7 @@ impl BlocklistStore { pub fn add_to_allowlist(&mut self, domain: &str) { let d = domain.to_lowercase(); - self.allowlist - .insert(d.trim_end_matches('.').to_string()); + self.allowlist.insert(d.trim_end_matches('.').to_string()); } pub fn remove_from_allowlist(&mut self, domain: &str) -> bool { -- 2.34.1 From c3138990a824749343f9fe9944671db1a7943ca7 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 10 Apr 2026 21:37:24 +0300 Subject: [PATCH 5/5] refactor: extract normalize() for domain lowering + dot stripping Co-Authored-By: Claude Opus 4.6 --- src/blocklist.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/blocklist.rs b/src/blocklist.rs index 8ba2fe7..ef865c4 100644 --- a/src/blocklist.rs +++ b/src/blocklist.rs @@ -86,12 +86,11 @@ impl BlocklistStore { return false; } } - let domain = domain.to_lowercase(); - let domain = domain.trim_end_matches('.'); - if Self::find_in_set(domain, &self.allowlist).is_some() { + let domain = Self::normalize(domain); + if Self::find_in_set(&domain, &self.allowlist).is_some() { return false; } - Self::find_in_set(domain, &self.domains).is_some() + Self::find_in_set(&domain, &self.domains).is_some() } pub fn check(&self, domain: &str) -> BlockCheckResult { @@ -105,10 +104,9 @@ impl BlocklistStore { } } - let domain = domain.to_lowercase(); - let domain = domain.trim_end_matches('.'); + let domain = Self::normalize(domain); - if let Some(matched) = Self::find_in_set(domain, &self.allowlist) { + if let Some(matched) = Self::find_in_set(&domain, &self.allowlist) { let reason = if matched == domain { "exact match in allowlist" } else { @@ -117,7 +115,7 @@ impl BlocklistStore { return BlockCheckResult::allowed(matched, reason); } - if let Some(matched) = Self::find_in_set(domain, &self.domains) { + if let Some(matched) = Self::find_in_set(&domain, &self.domains) { let reason = if matched == domain { "exact match in blocklist" } else { @@ -129,6 +127,10 @@ impl BlocklistStore { BlockCheckResult::not_blocked() } + fn normalize(domain: &str) -> String { + domain.to_lowercase().trim_end_matches('.').to_string() + } + fn find_in_set<'a>(domain: &'a str, set: &HashSet) -> Option<&'a str> { if set.contains(domain) { return Some(domain); @@ -174,13 +176,11 @@ impl BlocklistStore { } pub fn add_to_allowlist(&mut self, domain: &str) { - let d = domain.to_lowercase(); - self.allowlist.insert(d.trim_end_matches('.').to_string()); + self.allowlist.insert(Self::normalize(domain)); } pub fn remove_from_allowlist(&mut self, domain: &str) -> bool { - let d = domain.to_lowercase(); - self.allowlist.remove(d.trim_end_matches('.')) + self.allowlist.remove(&Self::normalize(domain)) } pub fn allowlist(&self) -> Vec { -- 2.34.1