From 3d52e43407627fe7f4bbf9910f26ed8ee34643b8 Mon Sep 17 00:00:00 2001 From: mHost Developer Date: Thu, 24 Sep 2026 08:49:41 +0800 Subject: [PATCH 1/3] feat(adblock): support domains-format blocklist sources (issue #213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosts-format parsing silently dropped bare-domain lines, so lists like anti-AD's domains.txt fetched 'successfully' with 0 rules and blocking never engaged — worse than an error because nothing surfaced it. - AdBlockSource gains an explicit `format` field (hosts | domains), chosen at add time; no runtime sniffing. #[serde(default)] = Hosts, so legacy adblock.json needs no migration (always serialized per the issue #202 lesson). - New parse_domains_blocklist reader: trim, skip blanks/# comments, lowercase + validate via the existing whitelist domain rules. The hosts path now also counts ParseResult.errors (previously discarded silently). - Anti-silent-failure contract in fetch_and_cache_source: 0 rules + malformed lines → hard failure (old cache kept, last_error points at the format setting); errors >= 10 and > 5% of kept rules → rules go live AND last_error carries an advisory warning. - Cache stays canonical hosts text, so the DNS engine / overlap report paths are untouched. - UI: Format dropdown on the add-source form (shared by bulk add), format shown in the source card meta line, copy updated. Tests: 8 new backend tests (domains reader, threshold boundaries, end-to-end domains fetch, loud 0-rule failure, warning path, serde back-compat), 1 new frontend test, existing assertions updated. --- src-tauri/crates/mhost-core/src/models.rs | 61 +++ src-tauri/src/commands/adblock.rs | 500 ++++++++++++++++++++-- src-tauri/src/commands/dns.rs | 1 + src/lib/tauri.ts | 4 +- src/pages/AdBlock.tsx | 40 +- src/pages/__tests__/AdBlock.test.tsx | 38 ++ src/stores/profiles/actions.ts | 10 +- src/types/index.ts | 15 + 8 files changed, 634 insertions(+), 35 deletions(-) diff --git a/src-tauri/crates/mhost-core/src/models.rs b/src-tauri/crates/mhost-core/src/models.rs index be09308..280eed5 100644 --- a/src-tauri/crates/mhost-core/src/models.rs +++ b/src-tauri/crates/mhost-core/src/models.rs @@ -215,6 +215,23 @@ pub enum AdBlockResponse { NxDomain, } +/// File format of an upstream blocklist (issue #213). +/// +/// `Hosts` — classic hosts syntax (`0.0.0.0 example.com`), parsed by +/// `mhost-hosts`'s `Parser`. `Domains` — one bare domain per line +/// (anti-AD domains 版, oisd, Peter Lowe's list), parsed by a +/// lightweight line reader in `commands::adblock`. +/// +/// Explicitly chosen at source-add time; no runtime format sniffing — +/// sniffing produced silent 0-rule failures (the bug this issue fixes). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum BlocklistFormat { + #[default] + Hosts, + Domains, +} + /// A remote ad block subscription source. /// /// One source = one URL of hosts-format blocklist. Persisted as part of @@ -228,6 +245,13 @@ pub struct AdBlockSource { pub url: String, pub enabled: bool, pub response: AdBlockResponse, + /// 上游 blocklist 的文件格式(issue #213)。默认 `hosts` —— 旧 + /// `adblock.json` 文档没有此键,`#[serde(default)]` 反序列化为 + /// `Hosts`,行为与旧版完全一致。Always serialized(issue #202 + /// 教训:绝不 `skip_serializing_if`,前端类型是 + /// `BlocklistFormat` 而非 `| null`)。 + #[serde(default)] + pub format: BlocklistFormat, /// RFC 3339 timestamp of the last successful fetch. `None` if never fetched. /// /// **Issue #202:** the `Option` fields on this struct serialize @@ -887,6 +911,7 @@ mod tests { url: "https://example.com/list.txt".to_string(), enabled: true, response: AdBlockResponse::NxDomain, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -912,6 +937,7 @@ mod tests { url: "https://example.com/list.txt".to_string(), enabled: false, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Domains, last_fetched_at: Some("2026-07-28T00:00:00Z".parse().unwrap()), last_error: Some("timeout".to_string()), rule_count: 42, @@ -948,6 +974,7 @@ mod tests { url: "https://example.com/s1".to_string(), enabled: true, response: AdBlockResponse::NxDomain, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 100, @@ -977,6 +1004,7 @@ mod tests { url: "https://example.com/big.txt".to_string(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 612_003, @@ -1000,6 +1028,39 @@ mod tests { assert_eq!(restored.rules_limit_override, None); } + // Issue #213: `BlocklistFormat` wire format is snake_case, default Hosts. + #[test] + fn test_blocklist_format_serde_roundtrip() { + for (variant, wire) in [ + (BlocklistFormat::Hosts, "\"hosts\""), + (BlocklistFormat::Domains, "\"domains\""), + ] { + let json = serde_json::to_string(&variant).unwrap(); + assert_eq!(json, wire); + let restored: BlocklistFormat = serde_json::from_str(&json).unwrap(); + assert_eq!(variant, restored); + } + assert_eq!(BlocklistFormat::default(), BlocklistFormat::Hosts); + } + + // Issue #213: legacy `adblock.json` documents predate the `format` + // key — they must deserialize as `Hosts` (the only format the old + // parser understood), never fail. + #[test] + fn test_ad_block_source_format_legacy_doc_defaults_to_hosts() { + let legacy = format!( + "{{\"source_id\":\"{}\",\"name\":\"L\",\"url\":\"https://x\",\ + \"enabled\":true,\"response\":\"zero_address\",\"rule_count\":1}}", + Uuid::new_v4() + ); + let restored: AdBlockSource = serde_json::from_str(&legacy).unwrap(); + assert_eq!(restored.format, BlocklistFormat::Hosts); + + // And the field serializes unconditionally (issue #202 lesson). + let json = serde_json::to_string(&restored).unwrap(); + assert!(json.contains("\"format\":\"hosts\""), "{}", json); + } + // ----------------------------------------------------------------------- // ID type tests // ----------------------------------------------------------------------- diff --git a/src-tauri/src/commands/adblock.rs b/src-tauri/src/commands/adblock.rs index 0f6af43..57ad002 100644 --- a/src-tauri/src/commands/adblock.rs +++ b/src-tauri/src/commands/adblock.rs @@ -13,7 +13,9 @@ use std::sync::{Arc, OnceLock}; use std::time::Duration; use chrono::Utc; -use mhost_core::{AdBlockResponse, AdBlockSource, AdBlockState, MhostError, SourceId}; +use mhost_core::{ + AdBlockResponse, AdBlockSource, AdBlockState, BlocklistFormat, MhostError, SourceId, +}; use mhost_hosts::Parser; use mhost_storage::adblock as adblock_store; use serde::{Deserialize, Serialize}; @@ -74,6 +76,17 @@ const MAX_DOMAIN_LEN: usize = 253; /// loop, blocking the UI for up to N × FETCH_TIMEOUT_SECS). pub(crate) const REFRESH_CONCURRENCY: usize = 4; +/// Parse-error warning thresholds (issue #213). A successful fetch whose +/// body produced some rules but also dropped lines is only worth +/// flagging when the dropped share is both absolutely and relatively +/// significant: at least [`PARSE_ERROR_WARN_MIN`] lines AND more than +/// 1 in [`PARSE_ERROR_WARN_RATIO_DEN`] of the kept rules. Below that the +/// noise (stray blank-ish lines, oddball entries in real-world lists) +/// would train users to ignore the badge. Above it, the most likely +/// cause is a wrong `format` setting — the warning says so. +const PARSE_ERROR_WARN_MIN: usize = 10; +const PARSE_ERROR_WARN_RATIO_DEN: usize = 20; + const USER_AGENT: &str = "mHost-Desktop/1.0"; // --------------------------------------------------------------------------- @@ -373,7 +386,7 @@ pub(crate) fn classify_rules( /// the cache file is missing or fails to parse (caller logs and continues). pub(crate) fn domains_for_source(root: &std::path::Path, source: &AdBlockSource) -> Vec { match adblock_store::read_cache(root, &source.source_id) { - Ok(Some(content)) => parse_blocklist_domains(&content), + Ok(Some(content)) => parse_blocklist_domains(&content).domains, Ok(None) => Vec::new(), Err(e) => { eprintln!( @@ -482,8 +495,40 @@ fn validate_whitelist_domain(raw: &str) -> Result { Ok(trimmed) } +/// Outcome of parsing a blocklist body (issue #213): the accepted +/// domains plus the number of non-comment lines that failed to parse. +/// `error_count` powers the anti-silent-failure contract — the pre-#213 +/// code discarded `ParseResult.errors`, which is how a domains-format +/// list fed to the hosts parser could yield 0 rules with no signal. +#[derive(Debug, Default)] +pub(crate) struct BlocklistParse { + pub domains: Vec, + pub error_count: usize, +} + +/// Decide whether a successful parse dropped enough lines to warrant a +/// user-visible warning (issue #213). See [`PARSE_ERROR_WARN_MIN`] / +/// [`PARSE_ERROR_WARN_RATIO_DEN`] for the threshold rationale. +fn parse_error_warning(parsed: &BlocklistParse) -> Option { + if parsed.error_count >= PARSE_ERROR_WARN_MIN + && parsed.error_count > parsed.domains.len() / PARSE_ERROR_WARN_RATIO_DEN + { + Some(format!( + "{} lines failed to parse ({} rules kept); if this looks wrong, \ + check the source format setting (hosts vs domains)", + parsed.error_count, + parsed.domains.len() + )) + } else { + None + } +} + /// Parse hosts-format blocklist content into a flat list of domains. -/// Comments (`#`) and empty lines are filtered out by `Parser::parse_line`. +/// Comments (`#`) and empty lines are filtered out by `Parser::parse_line`; +/// malformed non-comment lines land in `ParseResult.errors` and are +/// counted into [`BlocklistParse::error_count`] (issue #213 — previously +/// they were silently dropped). /// /// **PR #154 review (P2)**: no-op — after analysis, the original /// `d.to_lowercase()` is correct and the only allocation we can avoid @@ -496,7 +541,7 @@ fn validate_whitelist_domain(raw: &str) -> Result { /// straightforward `to_lowercase()` — the work runs in /// `spawn_blocking` (PR #131 P1-2 + issue #133), so DNS queries /// aren't blocked during the parse. -fn parse_blocklist_domains(content: &str) -> Vec { +fn parse_blocklist_domains(content: &str) -> BlocklistParse { let result = Parser::parse(content); let mut domains: Vec = Vec::new(); for rule in result.rules { @@ -507,7 +552,44 @@ fn parse_blocklist_domains(content: &str) -> Vec { domains.push(d.to_lowercase()); } } - domains + BlocklistParse { + domains, + error_count: result.errors.len(), + } +} + +/// Parse a domains-format blocklist (issue #213): one bare domain per +/// line — the shape used by anti-AD's `domains.txt`, oisd, Peter Lowe's +/// ad servers list, etc. +/// +/// Per line: trim → skip empty lines and `#` comments (inline comments +/// are stripped too) → validate via [`validate_whitelist_domain`] +/// (which also lowercases and enforces the RFC 1123 label structure). +/// Invalid lines are counted, never silently kept: the fetch path turns +/// "0 rules + N errors" into a hard failure and "some rules + many +/// errors" into a `last_error` warning, so a wrong `format` setting +/// can never again degrade into an invisible empty blocklist. +fn parse_domains_blocklist(content: &str) -> BlocklistParse { + let mut domains: Vec = Vec::new(); + let mut error_count: usize = 0; + for line in content.lines() { + // Strip inline comments first: `example.com # comment` is not a + // thing in the canonical domains lists, but tolerating it costs + // one `split` and keeps the reader robust against hand-edited + // files. `#` is not a valid domain character, so this is safe. + let candidate = line.split('#').next().unwrap_or("").trim(); + if candidate.is_empty() { + continue; + } + match validate_whitelist_domain(candidate) { + Ok(d) => domains.push(d), + Err(_) => error_count += 1, + } + } + BlocklistParse { + domains, + error_count, + } } /// Fetch a remote blocklist over HTTP(S), validate, and persist the raw @@ -569,8 +651,9 @@ pub(crate) async fn fetch_and_cache_source( // 1. Read the source record under the read lock. We capture both // the URL (for the fetch) and the previous ETag / last_fetched_at // (for the conditional GET — issue #193), plus the effective - // rules limit for this source (issue #207). - let (url, if_none_match, if_modified_since, rules_limit) = { + // rules limit for this source (issue #207) and the declared + // blocklist format (issue #213 — selects the parser). + let (url, if_none_match, if_modified_since, rules_limit, format) = { let guard = ad_block_state.read().await; match adblock_store::find_source(&guard, source_id) { Some(s) => ( @@ -578,6 +661,7 @@ pub(crate) async fn fetch_and_cache_source( s.etag.clone(), s.last_fetched_at.map(rfc7231_date), s.rules_limit_override.unwrap_or(MAX_RULES_PER_SOURCE), + s.format, ), None => { return Err(MhostError::InvalidInput(format!( @@ -602,6 +686,10 @@ pub(crate) async fn fetch_and_cache_source( Fresh { rule_count: usize, etag: Option, + /// Parse-error warning (issue #213): `Some` when the fetch + /// succeeded but dropped enough malformed lines to be worth + /// surfacing on `last_error`; `None` clears it. + warning: Option, }, NotModified, } @@ -645,26 +733,47 @@ pub(crate) async fn fetch_and_cache_source( let content_str = std::str::from_utf8(&body).map_err(|e| { MhostError::InvalidInput(format!("response is not valid UTF-8: {}", e)) })?; - let domains = parse_blocklist_domains(content_str); - if domains.len() > rules_limit { + // Issue #213: the parser is selected by the source's + // declared format — never sniffed at runtime. + let parsed = match format { + BlocklistFormat::Hosts => parse_blocklist_domains(content_str), + BlocklistFormat::Domains => parse_domains_blocklist(content_str), + }; + if parsed.domains.len() > rules_limit { return Err(MhostError::InvalidInput(format!( "source produced {} rules (limit: {})", - domains.len(), + parsed.domains.len(), rules_limit ))); } + // Issue #213 (anti-silent-failure): a body that yields + // zero rules AND has malformed lines almost certainly + // means the wrong `format` was picked (e.g. a domains + // list fed to the hosts parser). Fail loudly and keep + // the previous cache instead of caching an empty + // blocklist that silently stops blocking. + if parsed.domains.is_empty() && parsed.error_count > 0 { + return Err(MhostError::InvalidInput(format!( + "parsed 0 rules but {} lines failed to parse — \ + check the source format setting (hosts vs domains)", + parsed.error_count + ))); + } + let warning = parse_error_warning(&parsed); // Re-serialize as canonical hosts text so the cache is // always valid hosts format (drops comments the original // may have). Skipped entirely on 304 — see issue #193. - let canon = domains + let canon = parsed + .domains .iter() .map(|d| format!("0.0.0.0 {}", d)) .collect::>() .join("\n"); adblock_store::write_cache(&root, &id_owned, canon.as_bytes())?; Ok(Parsed::Fresh { - rule_count: domains.len(), + rule_count: parsed.domains.len(), etag, + warning, }) } } @@ -680,12 +789,19 @@ pub(crate) async fn fetch_and_cache_source( let elapsed_ms = started_at.elapsed().as_millis() as u64; match fetch_parse { - Ok(Parsed::Fresh { rule_count, etag }) => { - // 200 OK path — full update: clear error, set fetched_at, - // rule_count, and the new ETag. + Ok(Parsed::Fresh { + rule_count, + etag, + warning, + }) => { + // 200 OK path — full update: set fetched_at, rule_count, and + // the new ETag. `last_error` is cleared on a clean parse, or + // set to the parse-error warning when the body dropped enough + // malformed lines to be worth flagging (issue #213) — the + // rules ARE live in this case, the badge is advisory. let mut guard = ad_block_state.write().await; if let Some(s) = adblock_store::find_source_mut(&mut guard, source_id) { - s.last_error = None; + s.last_error = warning; s.last_fetched_at = Some(Utc::now()); s.rule_count = rule_count; s.etag = etag; @@ -1196,9 +1312,10 @@ pub async fn add_ad_block_source( name: String, url: String, response: AdBlockResponse, + format: BlocklistFormat, state: State<'_, AppState>, ) -> Result { - add_ad_block_source_impl(&state, name, url, response).await + add_ad_block_source_impl(&state, name, url, response, format).await } /// `AppState`-by-ref impl so the persistence-on-fetch-failure contract @@ -1208,6 +1325,7 @@ pub(crate) async fn add_ad_block_source_impl( name: String, url: String, response: AdBlockResponse, + format: BlocklistFormat, ) -> Result { if name.trim().is_empty() { return Err(MhostError::InvalidInput("source name is empty".into())); @@ -1232,6 +1350,7 @@ pub(crate) async fn add_ad_block_source_impl( url, enabled: true, response, + format, last_fetched_at: None, last_error: None, rule_count: 0, @@ -1822,6 +1941,7 @@ mod tests { url: "https://x".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 1, @@ -1879,6 +1999,7 @@ mod tests { url: "https://x".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 2, @@ -1893,6 +2014,7 @@ mod tests { url: "https://y".into(), enabled: true, response: AdBlockResponse::NxDomain, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 2, @@ -1962,6 +2084,7 @@ mod tests { url: "https://x".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 1, @@ -1976,6 +2099,7 @@ mod tests { url: "https://y".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 1, @@ -2017,6 +2141,7 @@ mod tests { url: "https://x".into(), enabled, response, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -2106,6 +2231,7 @@ mod tests { url: "https://x".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 2, @@ -2120,6 +2246,7 @@ mod tests { url: "https://y".into(), enabled: true, response: AdBlockResponse::NxDomain, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 2, @@ -2198,6 +2325,7 @@ mod tests { url: "https://x".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 1, @@ -2212,6 +2340,7 @@ mod tests { url: "https://y".into(), enabled: false, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 1, @@ -2260,6 +2389,7 @@ mod tests { url: "https://x".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 1, @@ -2316,6 +2446,7 @@ mod tests { url: "https://x".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 1, @@ -2330,6 +2461,7 @@ mod tests { url: "https://y".into(), enabled: true, response: AdBlockResponse::NxDomain, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 1, @@ -2425,6 +2557,7 @@ mod tests { url: "https://x".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -2464,18 +2597,35 @@ mod tests { # comment "; - let domains = parse_blocklist_domains(text); + let parsed = parse_blocklist_domains(text); + let domains = parsed.domains; assert!(domains.contains(&"ad.example.com".to_string())); assert!(domains.contains(&"tracker.example.com".to_string())); assert!(domains.contains(&"also.example.com".to_string())); - // comments and blanks are filtered by the parser + // comments and blanks are filtered by the parser — not errors + assert_eq!(parsed.error_count, 0); } #[test] fn parse_blocklist_lowercases() { let text = "0.0.0.0 MiXed.ExAmPlE.com\n"; - let domains = parse_blocklist_domains(text); - assert_eq!(domains, vec!["mixed.example.com".to_string()]); + let parsed = parse_blocklist_domains(text); + assert_eq!(parsed.domains, vec!["mixed.example.com".to_string()]); + } + + #[test] + fn parse_blocklist_counts_malformed_lines() { + // Bare-domain lines are malformed in hosts format — the pre-#213 + // behaviour silently dropped them; now they surface in + // error_count (this is the wrong-format signal). + let text = "\ +0.0.0.0 good.example.com +bare.example.com +another-bare.example.com +"; + let parsed = parse_blocklist_domains(text); + assert_eq!(parsed.domains, vec!["good.example.com".to_string()]); + assert_eq!(parsed.error_count, 2); } // ----------------------------------------------------------------- @@ -2649,6 +2799,7 @@ mod tests { url: "https://x".into(), enabled: true, response, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 2, @@ -2711,6 +2862,7 @@ mod tests { url: "https://x".into(), enabled: true, response, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -2930,6 +3082,7 @@ mod tests { url: "https://x".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 1, @@ -3092,10 +3245,15 @@ mod tests { // Port 1 on loopback refuses connections → fetch_source errors fast. let url = "http://127.0.0.1:1/blocklist".to_string(); - let err = - add_ad_block_source_impl(&state, "failing".into(), url, AdBlockResponse::ZeroAddress) - .await - .expect_err("fetch should fail (connection refused)"); + let err = add_ad_block_source_impl( + &state, + "failing".into(), + url, + AdBlockResponse::ZeroAddress, + BlocklistFormat::Hosts, + ) + .await + .expect_err("fetch should fail (connection refused)"); assert!( err.to_string().contains("fetch") || err.to_string().to_lowercase().contains("connect") @@ -3742,6 +3900,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -3871,6 +4030,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -3948,6 +4108,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: Some(prior_fetch), last_error: Some("prior boom".into()), rule_count: 7, @@ -4093,6 +4254,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -4174,6 +4336,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -4246,6 +4409,7 @@ mod tests { url: "https://x".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -4322,6 +4486,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: Some(chrono::Utc::now() - chrono::Duration::hours(1)), last_error: None, rule_count: 7, // stale bookkeeping from a wiped cache @@ -4401,6 +4566,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: Some(chrono::Utc::now() - chrono::Duration::hours(1)), last_error: None, rule_count: 7, // stale bookkeeping from a wiped cache @@ -4482,6 +4648,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -4569,6 +4736,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: Some(chrono::Utc::now() - chrono::Duration::hours(1)), last_error: None, rule_count: 1, @@ -4630,6 +4798,7 @@ mod tests { url: "https://x.example/list".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: Some(chrono::Utc::now()), last_error: None, rule_count: 3, @@ -4693,6 +4862,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: false, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, // Pre-disable bookkeeping — the fetch on re-enable must // overwrite this, not preserve the stale rule_count / // etag. We also pin a stale `last_error` so the @@ -4774,6 +4944,7 @@ mod tests { url: "https://x.example/list".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -4855,6 +5026,7 @@ mod tests { url: "https://x.example/list".into(), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -4917,6 +5089,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, // Pre-seed a stale failure timestamp so the // "successful fetch clears it" assertion is meaningful. @@ -4987,6 +5160,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, // Pre-existing cache + ETag (RFC 7232 conditional GET // requires a previous successful fetch). last_fetched_at: Some(chrono::Utc::now() - chrono::Duration::hours(1)), @@ -5065,6 +5239,7 @@ mod tests { url: format!("http://127.0.0.1:{}/list", port), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, @@ -5263,4 +5438,279 @@ mod tests { ); let _ = AdBlockAction::ZeroAddress; // keep the import used } + + // ----------------------------------------------------------------- + // Issue #213: domains-format blocklist sources. + // ----------------------------------------------------------------- + + #[test] + fn parse_domains_blocklist_happy_path() { + let text = "\ +# anti-AD style header comment +ads.example.com + +Tracker.Example.COM +cdn.ads.net # inline comment tolerated +"; + let parsed = parse_domains_blocklist(text); + assert_eq!( + parsed.domains, + vec![ + "ads.example.com".to_string(), + "tracker.example.com".to_string(), + "cdn.ads.net".to_string(), + ] + ); + assert_eq!(parsed.error_count, 0); + } + + #[test] + fn parse_domains_blocklist_counts_invalid_lines() { + // Each of these violates the whitelist validation rules and must + // be counted, never silently kept. A hosts-format line is the + // most important case: it means the user picked `domains` for a + // hosts list (or vice versa) and we must surface that. + let text = "\ +good.example.com +0.0.0.0 hosts-format-line.example.com +example.com/path +*.wildcard.example.com +-leading-dash.example.com +trailing-dot.example.com. +"; + let parsed = parse_domains_blocklist(text); + assert_eq!(parsed.domains, vec!["good.example.com".to_string()]); + assert_eq!(parsed.error_count, 5); + } + + #[test] + fn parse_error_warning_thresholds() { + // Below the absolute minimum → silent. + let below_min = BlocklistParse { + domains: (0..100).map(|i| format!("d{}.example.com", i)).collect(), + error_count: 9, + }; + assert!(parse_error_warning(&below_min).is_none()); + + // Above the minimum but below the ratio (10 errors vs 1000 rules + // is 1%) → silent. + let below_ratio = BlocklistParse { + domains: (0..1000).map(|i| format!("d{}.example.com", i)).collect(), + error_count: 10, + }; + assert!(parse_error_warning(&below_ratio).is_none()); + + // Above both → warning mentioning the format hint. + let above = BlocklistParse { + domains: (0..100).map(|i| format!("d{}.example.com", i)).collect(), + error_count: 10, + }; + let warning = parse_error_warning(&above).expect("threshold exceeded"); + assert!(warning.contains("10 lines failed to parse"), "{}", warning); + assert!(warning.contains("100 rules kept"), "{}", warning); + assert!(warning.contains("format"), "{}", warning); + + // Zero rules with zero errors (pure-comment file) → no warning; + // the 0-rules-with-errors case is a hard failure upstream and + // never reaches this helper. + let empty = BlocklistParse::default(); + assert!(parse_error_warning(&empty).is_none()); + } + + /// Issue #213 acceptance 1: a domains-format source parses bare + /// domains end-to-end — rule_count is correct and the on-disk cache + /// is canonical hosts text (so `domains_for_source` / the DNS engine + /// path needs no format awareness). + #[tokio::test] + async fn fetch_and_cache_source_domains_format_parses_bare_domains() { + use mhost_storage::adblock as adblock_store; + use mhost_storage::storage::FileStorage; + + let listener = bind_mock_listener(); + let port = listener.local_addr().unwrap().port(); + let body = b"# domains list\nads.example.com\nTracker.Example.COM\n"; + let responses = std::sync::Arc::new(std::sync::Mutex::new( + std::collections::VecDeque::from(vec![MockResponse::ok_200("\"d1\"", body)]), + )); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (_h, _recorded) = spawn_mock_http(listener, responses, stop.clone()); + + let temp = tempfile::TempDir::new().unwrap(); + let storage = std::sync::Arc::new(FileStorage::new(temp.path())) + as std::sync::Arc; + let ad_block_state = std::sync::Arc::new(tokio::sync::RwLock::new(AdBlockState::default())); + + let source_id = SourceId(uuid::Uuid::new_v4()); + { + let mut g = ad_block_state.write().await; + g.sources.push(AdBlockSource { + source_id: source_id.clone(), + name: "domains-source".into(), + url: format!("http://127.0.0.1:{}/list", port), + enabled: true, + response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Domains, + last_fetched_at: None, + last_error: None, + rule_count: 0, + etag: None, + rules_limit_override: None, + last_refresh_duration_ms: None, + last_refresh_failed_at: None, + }); + } + + fetch_and_cache_source(&storage, &ad_block_state, &source_id, false) + .await + .expect("domains-format fetch should succeed"); + + let snap = { + let g = ad_block_state.read().await; + adblock_store::find_source(&g, &source_id).cloned().unwrap() + }; + assert_eq!(snap.rule_count, 2); + assert!(snap.last_error.is_none()); + assert_eq!(snap.etag.as_deref(), Some("\"d1\"")); + + // Cache is canonical hosts text, lowercased. + let cache = adblock_store::read_cache(temp.path(), &source_id) + .unwrap() + .expect("cache written"); + assert_eq!( + cache, "0.0.0.0 ads.example.com\n0.0.0.0 tracker.example.com", + "cache must be canonical hosts text regardless of source format" + ); + + stop_mock(&stop, _h); + } + + /// Issue #213 anti-silent-failure: a hosts-format source whose body + /// is actually a domains list must NOT degrade into "0 rules, no + /// error". The fetch fails loudly, `last_error` points at the format + /// setting, and no empty cache is written. + #[tokio::test] + async fn fetch_and_cache_source_zero_rules_with_errors_fails_loudly() { + use mhost_storage::adblock as adblock_store; + use mhost_storage::storage::FileStorage; + + let listener = bind_mock_listener(); + let port = listener.local_addr().unwrap().port(); + // Bare domains fed to the hosts parser → every line malformed. + let body = b"ads.example.com\ntracker.example.com\n"; + let responses = std::sync::Arc::new(std::sync::Mutex::new( + std::collections::VecDeque::from(vec![MockResponse::ok_200("\"v1\"", body)]), + )); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (_h, _recorded) = spawn_mock_http(listener, responses, stop.clone()); + + let temp = tempfile::TempDir::new().unwrap(); + let storage = std::sync::Arc::new(FileStorage::new(temp.path())) + as std::sync::Arc; + let ad_block_state = std::sync::Arc::new(tokio::sync::RwLock::new(AdBlockState::default())); + + let source_id = SourceId(uuid::Uuid::new_v4()); + { + let mut g = ad_block_state.write().await; + g.sources.push(AdBlockSource { + source_id: source_id.clone(), + name: "wrong-format".into(), + url: format!("http://127.0.0.1:{}/list", port), + enabled: true, + response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, + last_fetched_at: None, + last_error: None, + rule_count: 0, + etag: None, + rules_limit_override: None, + last_refresh_duration_ms: None, + last_refresh_failed_at: None, + }); + } + + let err = fetch_and_cache_source(&storage, &ad_block_state, &source_id, false) + .await + .expect_err("0 rules + malformed lines must be a hard failure"); + assert!(err.to_string().contains("0 rules"), "{}", err); + assert!(err.to_string().contains("format"), "{}", err); + + let snap = { + let g = ad_block_state.read().await; + adblock_store::find_source(&g, &source_id).cloned().unwrap() + }; + let last_error = snap.last_error.expect("last_error recorded on failure"); + assert!(last_error.contains("0 rules"), "{}", last_error); + assert!( + !adblock_store::cache_path(temp.path(), &source_id).exists(), + "no empty cache may be written on the hard-failure path" + ); + + stop_mock(&stop, _h); + } + + /// Issue #213 acceptance 4: enough malformed lines on an otherwise + /// successful fetch → the rules go live AND `last_error` carries the + /// warning so the UI badge shows it. + #[tokio::test] + async fn fetch_and_cache_source_warns_when_error_lines_exceed_threshold() { + use mhost_storage::adblock as adblock_store; + use mhost_storage::storage::FileStorage; + + let listener = bind_mock_listener(); + let port = listener.local_addr().unwrap().port(); + // 100 valid hosts lines + 10 bare-domain lines: 10 >= + // PARSE_ERROR_WARN_MIN and 10 > 100 / PARSE_ERROR_WARN_RATIO_DEN. + let mut body = String::new(); + for i in 0..100 { + body.push_str(&format!("0.0.0.0 good{}.example.com\n", i)); + } + for i in 0..10 { + body.push_str(&format!("bad{}.example.com\n", i)); + } + let responses = std::sync::Arc::new(std::sync::Mutex::new( + std::collections::VecDeque::from(vec![MockResponse::ok_200("\"v1\"", body.as_bytes())]), + )); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (_h, _recorded) = spawn_mock_http(listener, responses, stop.clone()); + + let temp = tempfile::TempDir::new().unwrap(); + let storage = std::sync::Arc::new(FileStorage::new(temp.path())) + as std::sync::Arc; + let ad_block_state = std::sync::Arc::new(tokio::sync::RwLock::new(AdBlockState::default())); + + let source_id = SourceId(uuid::Uuid::new_v4()); + { + let mut g = ad_block_state.write().await; + g.sources.push(AdBlockSource { + source_id: source_id.clone(), + name: "noisy".into(), + url: format!("http://127.0.0.1:{}/list", port), + enabled: true, + response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, + last_fetched_at: None, + last_error: None, + rule_count: 0, + etag: None, + rules_limit_override: None, + last_refresh_duration_ms: None, + last_refresh_failed_at: None, + }); + } + + fetch_and_cache_source(&storage, &ad_block_state, &source_id, false) + .await + .expect("fetch succeeds — the warning is advisory"); + + let snap = { + let g = ad_block_state.read().await; + adblock_store::find_source(&g, &source_id).cloned().unwrap() + }; + assert_eq!(snap.rule_count, 100, "valid rules still go live"); + let warning = snap.last_error.expect("warning recorded"); + assert!(warning.contains("10 lines failed to parse"), "{}", warning); + assert!(warning.contains("format"), "{}", warning); + + stop_mock(&stop, _h); + } } diff --git a/src-tauri/src/commands/dns.rs b/src-tauri/src/commands/dns.rs index 749dff4..3be01d4 100644 --- a/src-tauri/src/commands/dns.rs +++ b/src-tauri/src/commands/dns.rs @@ -907,6 +907,7 @@ mod tests { url: "http://127.0.0.1:1/blocklist".into(), enabled: true, response: mhost_core::AdBlockResponse::ZeroAddress, + format: mhost_core::BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0, diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 7bda816..ccc5a97 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -12,6 +12,7 @@ import type { AdBlockLimits, AdBlockSource, AdBlockResponse, + BlocklistFormat, AdBlockStats, AdBlockOverlapReport, } from "../types"; @@ -305,8 +306,9 @@ export async function addAdBlockSource( name: string, url: string, response: AdBlockResponse, + format: BlocklistFormat, ): Promise { - return invoke("add_ad_block_source", { name, url, response }); + return invoke("add_ad_block_source", { name, url, response, format }); } export async function removeAdBlockSource(sourceId: string): Promise { diff --git a/src/pages/AdBlock.tsx b/src/pages/AdBlock.tsx index ae20d37..cbb9009 100644 --- a/src/pages/AdBlock.tsx +++ b/src/pages/AdBlock.tsx @@ -32,7 +32,7 @@ import { } from "../stores/profiles"; import { useNavigate } from "react-router-dom"; import { useWebKitPointerDown } from "../hooks/useWebKitPointerDown"; -import type { AdBlockResponse } from "../types"; +import type { AdBlockResponse, BlocklistFormat } from "../types"; import styles from "./AdBlock.module.css"; // Issue #207: parse "source produced N rules (limit: M)" out of @@ -87,6 +87,10 @@ function AdBlock() { const [newName, setNewName] = useState(""); const [newUrl, setNewUrl] = useState(""); const [newResponse, setNewResponse] = useState("zero_address"); + // Issue #213: upstream blocklist format, chosen explicitly at add + // time (the backend never sniffs). Defaults to hosts — the format + // every source predating this feature uses. + const [newFormat, setNewFormat] = useState("hosts"); // Issue #215 §1: id of the source whose overlap drawer is // open, or null. The drawer reads from `overlapReport`. const [overlapDrawerSrcId, setOverlapDrawerSrcId] = useState(null); @@ -117,7 +121,7 @@ function AdBlock() { const handleAddSource = useCallback(() => { if (!newName.trim() || !newUrl.trim()) return; - addSource({ name: newName.trim(), url: newUrl.trim(), response: newResponse }) + addSource({ name: newName.trim(), url: newUrl.trim(), response: newResponse, format: newFormat }) .then(() => { setNewName(""); setNewUrl(""); @@ -125,7 +129,7 @@ function AdBlock() { .catch(() => { /* error in atom */ }); - }, [addSource, newName, newUrl, newResponse]); + }, [addSource, newName, newUrl, newResponse, newFormat]); /** * Issue #196: parse a multi-line paste into individual entries, drop @@ -214,7 +218,10 @@ function AdBlock() { // user sees the count and a sample of inputs that failed. void Promise.allSettled( entries.map((e) => - addSource({ name: e.name, url: e.url, response: newResponse }), + // Bulk-added lines share the form's current format selection + // (issue #213) — a paste of domains-format URLs needs one + // dropdown flip, not N re-adds. + addSource({ name: e.name, url: e.url, response: newResponse, format: newFormat }), ), ).then((results) => { const failures = results @@ -232,7 +239,7 @@ function AdBlock() { } }); }, - [addSource, newResponse, setError], + [addSource, newResponse, newFormat, setError], ); const handleIntervalChange = useCallback( @@ -355,7 +362,8 @@ function AdBlock() {

Sources

- Hosts-format blocklist URLs (one domain per line, IP ignored). + Blocklist subscription URLs — hosts format (0.0.0.0 domain) or + plain domains (one per line), picked via the Format dropdown.

@@ -395,6 +403,25 @@ function AdBlock() {
+ {/* Issue #213: explicit upstream format. `hosts` is the + default every pre-#213 source uses; `domains` covers + anti-AD domains.txt / oisd-style one-domain-per-line + lists. Not editable post-add — re-add the source to + change it (mirrors the no-edit-source IPC contract). */} +
+ + +
{src.rule_count.toLocaleString()} rules + {` · ${src.format} format`} {src.rules_limit_override != null && ` · limit ${src.rules_limit_override.toLocaleString()} (manually raised)`} {src.last_fetched_at && diff --git a/src/pages/__tests__/AdBlock.test.tsx b/src/pages/__tests__/AdBlock.test.tsx index f639a54..4664659 100644 --- a/src/pages/__tests__/AdBlock.test.tsx +++ b/src/pages/__tests__/AdBlock.test.tsx @@ -90,6 +90,7 @@ function makeSource(overrides: Partial = {}): AdBlockSource { url: "https://example.com/hosts", enabled: true, response: "zero_address", + format: "hosts", last_fetched_at: null, last_error: null, rule_count: 100, @@ -390,6 +391,40 @@ describe("AdBlock", () => { "MyList", "https://ml.com/hosts", "zero_address", + "hosts", + ); + }); + + // Issue #213: the Format dropdown defaults to hosts and a domains + // selection is passed through to the IPC as the 4th argument. + it("passes the selected blocklist format to add_ad_block_source", async () => { + const state = makeState(); + setStore((s) => s.set(adBlockStateAtom, state)); + mockGetAdBlockState.mockResolvedValue(state); + renderWithProviders(); + await screen.findByRole("heading", { name: "Sources" }); + // No sources in state → the only comboboxes are the add form's + // Response and Format selects (in DOM order). + const formatSelect = screen.getAllByRole("combobox")[1]; + expect(formatSelect).toHaveValue("hosts"); + await act(async () => { + fireEvent.change(screen.getByPlaceholderText("StevenBlack"), { + target: { value: "anti-AD" }, + }); + fireEvent.change(screen.getByPlaceholderText("https://example.com/hosts"), { + target: { value: "https://anti-ad.net/domains.txt" }, + }); + fireEvent.change(formatSelect, { target: { value: "domains" } }); + }); + const sourceAddBtn = screen.getAllByText("Add")[0]; + await act(async () => { + fireEvent.click(sourceAddBtn); + }); + expect(mockAddAdBlockSource).toHaveBeenCalledWith( + "anti-AD", + "https://anti-ad.net/domains.txt", + "zero_address", + "domains", ); }); @@ -567,16 +602,19 @@ describe("AdBlock", () => { "StevenBlack", "https://sb.com/hosts", "zero_address", + "hosts", ); expect(mockAddAdBlockSource).toHaveBeenCalledWith( "My List", "https://ml.com/hosts", "zero_address", + "hosts", ); expect(mockAddAdBlockSource).toHaveBeenCalledWith( "Third", "https://t.com/hosts", "zero_address", + "hosts", ); }); diff --git a/src/stores/profiles/actions.ts b/src/stores/profiles/actions.ts index 150d519..70e3084 100644 --- a/src/stores/profiles/actions.ts +++ b/src/stores/profiles/actions.ts @@ -1,5 +1,5 @@ import { atom } from "jotai"; -import type { Profile, AdBlockResponse } from "../../types"; +import type { Profile, AdBlockResponse, BlocklistFormat } from "../../types"; import { listProfiles, getProfile, @@ -698,11 +698,15 @@ export const setAdBlockAutoRefreshEnabledAtom = atom( export const addAdBlockSourceAtom = atom( null, - async (_get, set, args: { name: string; url: string; response: AdBlockResponse }) => { + async ( + _get, + set, + args: { name: string; url: string; response: AdBlockResponse; format: BlocklistFormat }, + ) => { set(isAdBlockLoadingAtom, true); set(adBlockErrorAtom, null); try { - await addAdBlockSource(args.name, args.url, args.response); + await addAdBlockSource(args.name, args.url, args.response, args.format); const state = await getAdBlockState(); set(adBlockStateAtom, state); } catch (err) { diff --git a/src/types/index.ts b/src/types/index.ts index a67f00e..6422789 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -55,12 +55,27 @@ export type RuleSource = export type AdBlockResponse = "zero_address" | "nx_domain"; +/** + * Issue #213: file format of an upstream blocklist. `hosts` is the + * classic `0.0.0.0 example.com` syntax; `domains` is one bare domain + * per line (anti-AD domains 版, oisd, Peter Lowe's list). Chosen + * explicitly at source-add time — the backend never sniffs at runtime. + * Mirrors `mhost_core::BlocklistFormat` (serde snake_case). + */ +export type BlocklistFormat = "hosts" | "domains"; + export interface AdBlockSource { source_id: string; name: string; url: string; enabled: boolean; response: AdBlockResponse; + /** + * Issue #213: declared upstream format. Always serialized by the + * backend (never `null` / `undefined`); documents written before the + * field existed deserialize as `"hosts"`. + */ + format: BlocklistFormat; last_fetched_at: string | null; last_error: string | null; rule_count: number; From 1803c8ce95e26013e32dc13eac7908ebaac910db Mon Sep 17 00:00:00 2001 From: mHost Developer Date: Thu, 24 Sep 2026 08:52:43 +0800 Subject: [PATCH 2/3] chore: gitignore .pnpm-store/ (local pnpm store residue) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c1d2b5f..2bfedad 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ pnpm-debug.log* # Dependencies node_modules/ +.pnpm-store/ .npm .pnp .pnp.js From adc54e5e5b456f7c63fef182b5fe228c702fe044 Mon Sep 17 00:00:00 2001 From: mHost Developer Date: Thu, 24 Sep 2026 08:57:08 +0800 Subject: [PATCH 3/3] fix(adblock): add format field to mhost-storage test fixture (issue #213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI clippy runs with --workspace, which compiles mhost-storage's lib tests — the sample_source fixture there was missed in 3d52e43 (local clippy without --workspace only covered the mhost package). Verified locally with cargo clippy/test --workspace --all-targets --all-features. --- src-tauri/crates/mhost-storage/src/adblock.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src-tauri/crates/mhost-storage/src/adblock.rs b/src-tauri/crates/mhost-storage/src/adblock.rs index ec6fbe3..f52a2d6 100644 --- a/src-tauri/crates/mhost-storage/src/adblock.rs +++ b/src-tauri/crates/mhost-storage/src/adblock.rs @@ -269,7 +269,7 @@ pub fn find_source_mut<'a>( #[cfg(test)] mod tests { use super::*; - use mhost_core::AdBlockResponse; + use mhost_core::{AdBlockResponse, BlocklistFormat}; use tempfile::TempDir; fn sample_source(name: &str) -> AdBlockSource { @@ -279,6 +279,7 @@ mod tests { url: format!("https://example.com/{}.txt", name), enabled: true, response: AdBlockResponse::ZeroAddress, + format: BlocklistFormat::Hosts, last_fetched_at: None, last_error: None, rule_count: 0,