mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-25 13:50:51 +02:00
816 lines
25 KiB
Rust
816 lines
25 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
use crate::app_dirs;
|
|
|
|
const REFRESH_INTERVAL: Duration = Duration::from_secs(43200); // 12 hours
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum BlocklistLevel {
|
|
#[default]
|
|
None,
|
|
Light,
|
|
Normal,
|
|
Pro,
|
|
ProPlus,
|
|
Ultimate,
|
|
/// User-defined list: compiled from custom source URLs + custom block
|
|
/// domains, with custom allow domains removed (allowlist overrides).
|
|
Custom,
|
|
}
|
|
|
|
impl BlocklistLevel {
|
|
pub fn parse_level(s: &str) -> Option<Self> {
|
|
match s {
|
|
"light" => Some(Self::Light),
|
|
"normal" => Some(Self::Normal),
|
|
"pro" => Some(Self::Pro),
|
|
"pro_plus" => Some(Self::ProPlus),
|
|
"ultimate" => Some(Self::Ultimate),
|
|
"custom" => Some(Self::Custom),
|
|
"none" => Some(Self::None),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::None => "none",
|
|
Self::Light => "light",
|
|
Self::Normal => "normal",
|
|
Self::Pro => "pro",
|
|
Self::ProPlus => "pro_plus",
|
|
Self::Ultimate => "ultimate",
|
|
Self::Custom => "custom",
|
|
}
|
|
}
|
|
|
|
pub fn display_name(&self) -> &'static str {
|
|
match self {
|
|
Self::None => "None",
|
|
Self::Light => "Light",
|
|
Self::Normal => "Normal",
|
|
Self::Pro => "Pro",
|
|
Self::ProPlus => "Pro++",
|
|
Self::Ultimate => "Ultimate",
|
|
Self::Custom => "Custom",
|
|
}
|
|
}
|
|
|
|
pub fn url(&self) -> Option<&'static str> {
|
|
match self {
|
|
Self::None | Self::Custom => None,
|
|
Self::Light => {
|
|
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
|
|
}
|
|
Self::Normal => {
|
|
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/multi.txt")
|
|
}
|
|
Self::Pro => Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.txt"),
|
|
Self::ProPlus => {
|
|
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.plus.txt")
|
|
}
|
|
Self::Ultimate => {
|
|
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/ultimate.txt")
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn filename(&self) -> Option<&'static str> {
|
|
match self {
|
|
Self::None => None,
|
|
Self::Light => Some("light.txt"),
|
|
Self::Normal => Some("multi.txt"),
|
|
Self::Pro => Some("pro.txt"),
|
|
Self::ProPlus => Some("pro.plus.txt"),
|
|
Self::Ultimate => Some("ultimate.txt"),
|
|
Self::Custom => Some("custom.txt"),
|
|
}
|
|
}
|
|
|
|
pub fn all_downloadable() -> &'static [BlocklistLevel] {
|
|
&[
|
|
Self::Light,
|
|
Self::Normal,
|
|
Self::Pro,
|
|
Self::ProPlus,
|
|
Self::Ultimate,
|
|
]
|
|
}
|
|
}
|
|
|
|
/// User-defined DNS filtering: extra blocklist source URLs plus manual block /
|
|
/// allow domain rules. Allow rules override blocks (the standard exceptions
|
|
/// model). Stored as one JSON blob; the compiled result lands in `custom.txt`.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct CustomDnsConfig {
|
|
#[serde(default)]
|
|
pub sources: Vec<String>,
|
|
#[serde(default)]
|
|
pub block_domains: Vec<String>,
|
|
#[serde(default)]
|
|
pub allow_domains: Vec<String>,
|
|
/// When true the custom list is a strict allowlist: the browser may only
|
|
/// reach `allow_domains` (and their subdomains); everything else is blocked.
|
|
/// Sources/block_domains are ignored in this mode.
|
|
#[serde(default)]
|
|
pub allowlist_mode: bool,
|
|
#[serde(default)]
|
|
pub updated_at: Option<u64>,
|
|
}
|
|
|
|
fn normalize_domain(raw: &str) -> Option<String> {
|
|
let mut line = raw.trim();
|
|
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
|
|
return None;
|
|
}
|
|
// Hosts-file format ("0.0.0.0 ads.example.com", "127.0.0.1 tracker.net") is
|
|
// common in public blocklists — take the domain after the sink IP rather
|
|
// than rejecting the whole line on the embedded space.
|
|
if let Some((first, rest)) = line.split_once(char::is_whitespace) {
|
|
if matches!(first, "0.0.0.0" | "127.0.0.1" | "::" | "::1") {
|
|
line = rest.trim();
|
|
}
|
|
}
|
|
// Strip a trailing comment ("0.0.0.0 ads.example.com # AdGuard"). Public
|
|
// hosts lists annotate entries this way; without this the whitespace guard
|
|
// below rejects every annotated line, silently dropping most of the source.
|
|
for marker in ['#', '!'] {
|
|
if let Some((before, _)) = line.split_once(marker) {
|
|
line = before.trim();
|
|
}
|
|
}
|
|
let d = line
|
|
.trim_start_matches("*.")
|
|
.trim_start_matches("||")
|
|
.trim_end_matches('^')
|
|
.trim_end_matches('.')
|
|
.to_lowercase();
|
|
if d.is_empty() {
|
|
return None;
|
|
}
|
|
// Reject anything still containing whitespace or a scheme — not a bare domain.
|
|
if d.contains(char::is_whitespace) || d.contains("://") {
|
|
return None;
|
|
}
|
|
Some(d)
|
|
}
|
|
|
|
impl CustomDnsConfig {
|
|
fn path() -> PathBuf {
|
|
app_dirs::data_subdir().join("custom_dns.json")
|
|
}
|
|
|
|
pub fn load() -> Self {
|
|
match std::fs::read_to_string(Self::path()) {
|
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
|
Err(_) => Self::default(),
|
|
}
|
|
}
|
|
|
|
pub fn save(&self) -> Result<(), String> {
|
|
let path = Self::path();
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
}
|
|
let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
|
|
std::fs::write(&path, json).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Serialize to a plain-text rule list (uBlock-ish): `! source:` comments for
|
|
/// source URLs, `@@domain` for allow rules, bare domains for blocks.
|
|
pub fn to_txt(&self) -> String {
|
|
let mut out = String::new();
|
|
for s in &self.sources {
|
|
out.push_str("! source: ");
|
|
out.push_str(s);
|
|
out.push('\n');
|
|
}
|
|
for d in &self.allow_domains {
|
|
out.push_str("@@");
|
|
out.push_str(d);
|
|
out.push('\n');
|
|
}
|
|
for d in &self.block_domains {
|
|
out.push_str(d);
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Parse a plain-text rule list back into a config (sources from
|
|
/// `! source:` lines, `@@`-prefixed as allow, bare domains as block).
|
|
pub fn from_txt(content: &str) -> Self {
|
|
let mut cfg = CustomDnsConfig::default();
|
|
for line in content.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
if let Some(src) = line.strip_prefix("! source:") {
|
|
let src = src.trim();
|
|
if !src.is_empty() {
|
|
cfg.sources.push(src.to_string());
|
|
}
|
|
continue;
|
|
}
|
|
if line.starts_with('#') || line.starts_with('!') {
|
|
continue;
|
|
}
|
|
if let Some(allow) = line.strip_prefix("@@") {
|
|
if let Some(d) = normalize_domain(allow) {
|
|
cfg.allow_domains.push(d);
|
|
}
|
|
} else if let Some(d) = normalize_domain(line) {
|
|
cfg.block_domains.push(d);
|
|
}
|
|
}
|
|
cfg.dedup();
|
|
cfg
|
|
}
|
|
|
|
/// Drop duplicates, preserving first-seen order so an exported rule list
|
|
/// still reads the way the user wrote it.
|
|
fn dedup(&mut self) {
|
|
for v in [
|
|
&mut self.sources,
|
|
&mut self.block_domains,
|
|
&mut self.allow_domains,
|
|
] {
|
|
let mut seen = std::collections::HashSet::new();
|
|
v.retain(|item| seen.insert(item.clone()));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BlocklistCacheStatus {
|
|
pub level: String,
|
|
pub display_name: String,
|
|
pub entry_count: usize,
|
|
pub file_size_bytes: u64,
|
|
pub last_updated: Option<u64>,
|
|
pub is_fresh: bool,
|
|
pub is_cached: bool,
|
|
}
|
|
|
|
pub struct BlocklistManager;
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref HTTP_CLIENT: reqwest::Client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(60))
|
|
.build()
|
|
.expect("Failed to create HTTP client");
|
|
}
|
|
|
|
impl BlocklistManager {
|
|
pub fn instance() -> &'static BlocklistManager {
|
|
&BLOCKLIST_MANAGER
|
|
}
|
|
|
|
fn cache_dir() -> PathBuf {
|
|
app_dirs::dns_blocklist_dir()
|
|
}
|
|
|
|
pub fn cached_file_path(level: BlocklistLevel) -> Option<PathBuf> {
|
|
level.filename().map(|f| Self::cache_dir().join(f))
|
|
}
|
|
|
|
pub fn is_cache_fresh(level: BlocklistLevel) -> bool {
|
|
let Some(path) = Self::cached_file_path(level) else {
|
|
return false;
|
|
};
|
|
if !path.exists() {
|
|
return false;
|
|
}
|
|
match std::fs::metadata(&path).and_then(|m| m.modified()) {
|
|
Ok(modified) => SystemTime::now()
|
|
.duration_since(modified)
|
|
.map(|age| age < REFRESH_INTERVAL)
|
|
.unwrap_or(false),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
|
|
let production_url = level
|
|
.url()
|
|
.ok_or_else(|| format!("No URL for level {:?}", level))?;
|
|
#[cfg(feature = "e2e")]
|
|
let url = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
|
|
.ok()
|
|
.filter(|base| !base.is_empty())
|
|
.map(|base| {
|
|
format!(
|
|
"{}/{}",
|
|
base.trim_end_matches('/'),
|
|
level.filename().unwrap_or("blocklist.txt")
|
|
)
|
|
})
|
|
.unwrap_or_else(|| production_url.to_string());
|
|
#[cfg(not(feature = "e2e"))]
|
|
let url = production_url.to_string();
|
|
let path =
|
|
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
|
|
|
|
let cache_dir = Self::cache_dir();
|
|
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
|
|
|
|
log::info!(
|
|
"[dns-blocklist] Fetching {} from {}",
|
|
level.display_name(),
|
|
url
|
|
);
|
|
|
|
let response = HTTP_CLIENT
|
|
.get(&url)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(format!("HTTP {} when fetching {}", response.status(), url));
|
|
}
|
|
|
|
let body = response
|
|
.text()
|
|
.await
|
|
.map_err(|e| format!("Failed to read response body: {e}"))?;
|
|
|
|
// Write atomically: write to temp file, then rename
|
|
let tmp_path = path.with_extension("tmp");
|
|
std::fs::write(&tmp_path, &body).map_err(|e| format!("Failed to write blocklist: {e}"))?;
|
|
std::fs::rename(&tmp_path, &path).map_err(|e| format!("Failed to rename blocklist: {e}"))?;
|
|
|
|
let entry_count = body
|
|
.lines()
|
|
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
|
|
.count();
|
|
log::info!(
|
|
"[dns-blocklist] Cached {} ({} domains)",
|
|
level.display_name(),
|
|
entry_count
|
|
);
|
|
|
|
Ok(path)
|
|
}
|
|
|
|
pub async fn ensure_cached(level: BlocklistLevel) -> Result<PathBuf, String> {
|
|
if level == BlocklistLevel::Custom {
|
|
// Recompile only when the compiled file is missing or stale. Edits call
|
|
// compile_custom_blocklist directly and refresh_all_stale keeps sources
|
|
// current, so recompiling unconditionally would re-download every source
|
|
// on every profile launch — and this is awaited on the blocking launch
|
|
// path, so the browser cannot start until it finishes.
|
|
if let Some(path) = Self::cached_file_path(level) {
|
|
if Self::is_cache_fresh(level) {
|
|
return Ok(path);
|
|
}
|
|
}
|
|
return Self::compile_custom_blocklist().await;
|
|
}
|
|
if let Some(path) = Self::cached_file_path(level) {
|
|
if path.exists() {
|
|
return Ok(path);
|
|
}
|
|
}
|
|
Self::fetch_blocklist(level).await
|
|
}
|
|
|
|
/// Compile the user's custom DNS file. In blocklist mode: fetch every source,
|
|
/// union with the manual block domains, then remove the allow domains
|
|
/// (allowlist overrides). In allowlist mode: the file is just the allow
|
|
/// domains — the worker then blocks everything NOT listed. Always rewrites
|
|
/// `custom.txt`.
|
|
pub async fn compile_custom_blocklist() -> Result<PathBuf, String> {
|
|
use std::collections::HashSet;
|
|
|
|
let config = CustomDnsConfig::load();
|
|
let mut domains: HashSet<String> = HashSet::new();
|
|
let path =
|
|
Self::cached_file_path(BlocklistLevel::Custom).ok_or("No filename for custom level")?;
|
|
|
|
if config.allowlist_mode {
|
|
// Strict allowlist: the compiled file is the set of permitted domains.
|
|
for d in &config.allow_domains {
|
|
if let Some(n) = normalize_domain(d) {
|
|
domains.insert(n);
|
|
}
|
|
}
|
|
} else {
|
|
// Fetch every source concurrently. Sequential awaits make this the sum of
|
|
// all source latencies, and it runs on the blocking profile-launch path.
|
|
let fetches = config.sources.iter().map(|source| async move {
|
|
match HTTP_CLIENT.get(source).send().await {
|
|
Ok(resp) if resp.status().is_success() => resp
|
|
.text()
|
|
.await
|
|
.map_err(|e| format!("custom source {source} body read failed: {e}")),
|
|
Ok(resp) => Err(format!(
|
|
"custom source {source} returned HTTP {}",
|
|
resp.status()
|
|
)),
|
|
Err(e) => Err(format!("custom source {source} failed: {e}")),
|
|
}
|
|
});
|
|
|
|
let mut source_failures = 0usize;
|
|
for result in futures_util::future::join_all(fetches).await {
|
|
match result {
|
|
Ok(body) => {
|
|
for line in body.lines() {
|
|
if let Some(d) = normalize_domain(line) {
|
|
domains.insert(d);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::warn!("[dns-blocklist] {e}");
|
|
source_failures += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// A failed source must never shrink the compiled list. Overwriting with a
|
|
// short (or empty) result would silently stop blocking whatever that
|
|
// source contributed — and `is_blocked` fails open on an empty set, so a
|
|
// single offline launch would disable custom filtering entirely and
|
|
// destroy the good cached list. Re-seed from the cached file so a network
|
|
// failure degrades to "stale" instead of "off"; manual rule edits below
|
|
// still apply, and the next successful compile rewrites cleanly.
|
|
if source_failures > 0 {
|
|
match std::fs::read_to_string(&path) {
|
|
Ok(cached) => {
|
|
let before = domains.len();
|
|
for line in cached.lines() {
|
|
if let Some(d) = normalize_domain(line) {
|
|
domains.insert(d);
|
|
}
|
|
}
|
|
log::warn!(
|
|
"[dns-blocklist] {source_failures} custom source(s) failed; retained {} domain(s) from the cached list",
|
|
domains.len().saturating_sub(before)
|
|
);
|
|
}
|
|
Err(_) => log::warn!(
|
|
"[dns-blocklist] {source_failures} custom source(s) failed and no cached list exists; custom filtering is incomplete"
|
|
),
|
|
}
|
|
}
|
|
|
|
for d in &config.block_domains {
|
|
if let Some(n) = normalize_domain(d) {
|
|
domains.insert(n);
|
|
}
|
|
}
|
|
// Allow rules override blocks (exceptions).
|
|
for d in &config.allow_domains {
|
|
if let Some(n) = normalize_domain(d) {
|
|
domains.remove(&n);
|
|
}
|
|
}
|
|
}
|
|
|
|
let cache_dir = Self::cache_dir();
|
|
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
|
|
|
|
let mut sorted: Vec<&str> = domains.iter().map(String::as_str).collect();
|
|
sorted.sort_unstable();
|
|
let body = sorted.join("\n");
|
|
|
|
let tmp_path = path.with_extension("tmp");
|
|
std::fs::write(&tmp_path, &body)
|
|
.map_err(|e| format!("Failed to write custom blocklist: {e}"))?;
|
|
std::fs::rename(&tmp_path, &path)
|
|
.map_err(|e| format!("Failed to rename custom blocklist: {e}"))?;
|
|
|
|
log::info!(
|
|
"[dns-blocklist] Compiled custom blocklist ({} domains)",
|
|
domains.len()
|
|
);
|
|
Ok(path)
|
|
}
|
|
|
|
pub async fn refresh_all_stale(&self) {
|
|
for &level in BlocklistLevel::all_downloadable() {
|
|
if !Self::is_cache_fresh(level) {
|
|
if let Err(e) = Self::fetch_blocklist(level).await {
|
|
log::error!(
|
|
"[dns-blocklist] Failed to refresh {}: {e}",
|
|
level.display_name()
|
|
);
|
|
let _ = crate::events::emit(
|
|
"dns-blocklist-refresh-failed",
|
|
serde_json::json!({
|
|
"level": level.as_str(),
|
|
"error": e,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// Recompile the custom list too so its sources track upstream changes.
|
|
let config = CustomDnsConfig::load();
|
|
if !config.sources.is_empty() || !config.block_domains.is_empty() {
|
|
if let Err(e) = Self::compile_custom_blocklist().await {
|
|
log::error!("[dns-blocklist] Failed to recompile custom list: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn get_blocklist_file_path(level: BlocklistLevel) -> Option<PathBuf> {
|
|
Self::cached_file_path(level).filter(|p| p.exists())
|
|
}
|
|
|
|
pub fn get_cache_status() -> Vec<BlocklistCacheStatus> {
|
|
BlocklistLevel::all_downloadable()
|
|
.iter()
|
|
.map(|&level| {
|
|
let path = Self::cached_file_path(level);
|
|
let metadata = path.as_ref().and_then(|p| std::fs::metadata(p).ok());
|
|
let is_cached = metadata.is_some();
|
|
|
|
let entry_count = if is_cached {
|
|
path
|
|
.as_ref()
|
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
|
.map(|content| {
|
|
content
|
|
.lines()
|
|
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
|
|
.count()
|
|
})
|
|
.unwrap_or(0)
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let file_size_bytes = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
|
|
|
|
let last_updated = metadata
|
|
.as_ref()
|
|
.and_then(|m| m.modified().ok())
|
|
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
|
.map(|d| d.as_secs());
|
|
|
|
BlocklistCacheStatus {
|
|
level: level.as_str().to_string(),
|
|
display_name: level.display_name().to_string(),
|
|
entry_count,
|
|
file_size_bytes,
|
|
last_updated,
|
|
is_fresh: Self::is_cache_fresh(level),
|
|
is_cached,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref BLOCKLIST_MANAGER: BlocklistManager = BlocklistManager;
|
|
}
|
|
|
|
// Tauri commands
|
|
|
|
#[tauri::command]
|
|
pub async fn get_dns_blocklist_cache_status() -> Result<Vec<BlocklistCacheStatus>, String> {
|
|
Ok(BlocklistManager::get_cache_status())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn refresh_dns_blocklists() -> Result<(), String> {
|
|
for &level in BlocklistLevel::all_downloadable() {
|
|
BlocklistManager::fetch_blocklist(level).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_custom_dns_config() -> Result<CustomDnsConfig, String> {
|
|
Ok(CustomDnsConfig::load())
|
|
}
|
|
|
|
/// Normalize, persist, recompile and announce a custom DNS config. Shared by
|
|
/// the set and import commands so both apply the same normalization and the
|
|
/// same side effects — a step added here reaches both.
|
|
async fn persist_custom_config(mut config: CustomDnsConfig) -> Result<CustomDnsConfig, String> {
|
|
config.sources = std::mem::take(&mut config.sources)
|
|
.into_iter()
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
config.block_domains = config
|
|
.block_domains
|
|
.iter()
|
|
.filter_map(|d| normalize_domain(d))
|
|
.collect();
|
|
config.allow_domains = config
|
|
.allow_domains
|
|
.iter()
|
|
.filter_map(|d| normalize_domain(d))
|
|
.collect();
|
|
config.updated_at = Some(crate::proxy_manager::now_secs());
|
|
config.dedup();
|
|
config
|
|
.save()
|
|
.map_err(|_| serde_json::json!({ "code": "DNS_RULES_SAVE_FAILED" }).to_string())?;
|
|
// Recompile so a profile relaunch picks up the new rules immediately.
|
|
let _ = BlocklistManager::compile_custom_blocklist().await;
|
|
let _ = crate::events::emit_empty("custom-dns-changed");
|
|
Ok(config)
|
|
}
|
|
|
|
/// Persist the custom DNS config and recompile the `custom.txt` list. Domains
|
|
/// are normalized; blank/comment entries are dropped.
|
|
#[tauri::command]
|
|
pub async fn set_custom_dns_config(
|
|
sources: Vec<String>,
|
|
block_domains: Vec<String>,
|
|
allow_domains: Vec<String>,
|
|
allowlist_mode: bool,
|
|
) -> Result<CustomDnsConfig, String> {
|
|
persist_custom_config(CustomDnsConfig {
|
|
sources,
|
|
block_domains,
|
|
allow_domains,
|
|
allowlist_mode,
|
|
updated_at: None,
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Import custom DNS rules. `format` is "json" (a full CustomDnsConfig) or
|
|
/// "txt" (uBlock-ish: `! source:`, `@@allow`, bare block domains).
|
|
#[tauri::command]
|
|
pub async fn import_custom_dns_rules(
|
|
content: String,
|
|
format: String,
|
|
) -> Result<CustomDnsConfig, String> {
|
|
let config = match format.as_str() {
|
|
"json" => serde_json::from_str::<CustomDnsConfig>(&content)
|
|
.map_err(|_| serde_json::json!({ "code": "INVALID_DNS_RULES_JSON" }).to_string())?,
|
|
"txt" => CustomDnsConfig::from_txt(&content),
|
|
other => {
|
|
return Err(
|
|
serde_json::json!({
|
|
"code": "UNSUPPORTED_DNS_RULES_FORMAT",
|
|
"params": { "format": other },
|
|
})
|
|
.to_string(),
|
|
)
|
|
}
|
|
};
|
|
persist_custom_config(config).await
|
|
}
|
|
|
|
/// Export the custom DNS rules as "json" or "txt".
|
|
#[tauri::command]
|
|
pub async fn export_custom_dns_rules(format: String) -> Result<String, String> {
|
|
let config = CustomDnsConfig::load();
|
|
match format.as_str() {
|
|
"json" => serde_json::to_string_pretty(&config)
|
|
.map_err(|_| serde_json::json!({ "code": "DNS_RULES_EXPORT_FAILED" }).to_string()),
|
|
"txt" => Ok(config.to_txt()),
|
|
other => Err(
|
|
serde_json::json!({
|
|
"code": "UNSUPPORTED_DNS_RULES_FORMAT",
|
|
"params": { "format": other },
|
|
})
|
|
.to_string(),
|
|
),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_level_roundtrip() {
|
|
for &level in BlocklistLevel::all_downloadable() {
|
|
let s = level.as_str();
|
|
let parsed = BlocklistLevel::parse_level(s);
|
|
assert_eq!(parsed, Some(level), "Roundtrip failed for {s}");
|
|
}
|
|
assert_eq!(
|
|
BlocklistLevel::parse_level("none"),
|
|
Some(BlocklistLevel::None)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_custom_config_txt_roundtrip() {
|
|
let txt = "! source: https://example.com/list.txt\n@@allowed.com\nblocked.com\n*.tracker.net\n";
|
|
let cfg = CustomDnsConfig::from_txt(txt);
|
|
assert_eq!(cfg.sources, vec!["https://example.com/list.txt"]);
|
|
assert_eq!(cfg.allow_domains, vec!["allowed.com"]);
|
|
assert_eq!(cfg.block_domains, vec!["blocked.com", "tracker.net"]);
|
|
// Re-exporting produces parseable text.
|
|
let reparsed = CustomDnsConfig::from_txt(&cfg.to_txt());
|
|
assert_eq!(reparsed.block_domains, cfg.block_domains);
|
|
assert_eq!(reparsed.allow_domains, cfg.allow_domains);
|
|
assert_eq!(reparsed.sources, cfg.sources);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_domain() {
|
|
assert_eq!(
|
|
normalize_domain("*.Example.COM"),
|
|
Some("example.com".into())
|
|
);
|
|
assert_eq!(normalize_domain("||ads.net^"), Some("ads.net".into()));
|
|
assert_eq!(normalize_domain(" "), None);
|
|
assert_eq!(normalize_domain("# comment"), None);
|
|
assert_eq!(normalize_domain("http://x.com"), None);
|
|
// Hosts-file format lines yield the domain, not None.
|
|
assert_eq!(
|
|
normalize_domain("0.0.0.0 ads.example.com"),
|
|
Some("ads.example.com".into())
|
|
);
|
|
assert_eq!(
|
|
normalize_domain("127.0.0.1\ttracker.net"),
|
|
Some("tracker.net".into())
|
|
);
|
|
// A bare two-token line that isn't hosts-format is still rejected.
|
|
assert_eq!(normalize_domain("foo bar"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_domain_strips_trailing_comments() {
|
|
// Public hosts lists (StevenBlack, AdAway) annotate entries inline. Without
|
|
// comment stripping the whitespace guard drops every annotated line, so a
|
|
// source compiles down to a fraction of its real domain count.
|
|
assert_eq!(
|
|
normalize_domain("0.0.0.0 ads.example.com # AdGuard"),
|
|
Some("ads.example.com".into())
|
|
);
|
|
assert_eq!(
|
|
normalize_domain("127.0.0.1 tracker.net #comment"),
|
|
Some("tracker.net".into())
|
|
);
|
|
assert_eq!(
|
|
normalize_domain("plain.example.com # trailing note"),
|
|
Some("plain.example.com".into())
|
|
);
|
|
assert_eq!(
|
|
normalize_domain("ads.example.com ! adblock note"),
|
|
Some("ads.example.com".into())
|
|
);
|
|
// A sink IP followed only by a comment has no domain left.
|
|
assert_eq!(normalize_domain("0.0.0.0 # just a comment"), None);
|
|
// Full-line comments stay rejected.
|
|
assert_eq!(normalize_domain("! adblock header"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_custom_level_roundtrip() {
|
|
assert_eq!(
|
|
BlocklistLevel::parse_level("custom"),
|
|
Some(BlocklistLevel::Custom)
|
|
);
|
|
assert_eq!(BlocklistLevel::Custom.as_str(), "custom");
|
|
assert_eq!(BlocklistLevel::Custom.filename(), Some("custom.txt"));
|
|
assert!(BlocklistLevel::Custom.url().is_none());
|
|
// Custom must NOT be in the auto-refresh set (no upstream URL).
|
|
assert!(!BlocklistLevel::all_downloadable().contains(&BlocklistLevel::Custom));
|
|
}
|
|
|
|
#[test]
|
|
fn test_level_urls_all_present() {
|
|
for &level in BlocklistLevel::all_downloadable() {
|
|
assert!(
|
|
level.url().is_some(),
|
|
"{} should have a URL",
|
|
level.as_str()
|
|
);
|
|
assert!(
|
|
level.filename().is_some(),
|
|
"{} should have a filename",
|
|
level.as_str()
|
|
);
|
|
}
|
|
assert!(BlocklistLevel::None.url().is_none());
|
|
assert!(BlocklistLevel::None.filename().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_status_returns_all_levels() {
|
|
let statuses = BlocklistManager::get_cache_status();
|
|
assert_eq!(statuses.len(), 5);
|
|
assert_eq!(statuses[0].level, "light");
|
|
assert_eq!(statuses[1].level, "normal");
|
|
assert_eq!(statuses[2].level, "pro");
|
|
assert_eq!(statuses[3].level, "pro_plus");
|
|
assert_eq!(statuses[4].level, "ultimate");
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_fresh_returns_false_when_missing() {
|
|
assert!(!BlocklistManager::is_cache_fresh(BlocklistLevel::Light));
|
|
assert!(!BlocklistManager::is_cache_fresh(BlocklistLevel::None));
|
|
}
|
|
}
|