etchdns/
nx_zones.rs

1use std::collections::HashSet;
2use std::fs::File;
3use std::io::{self, BufRead, BufReader};
4use std::path::Path;
5
6/// Manages a list of nonexistent DNS zones
7#[derive(Clone)]
8pub struct NxZones {
9    /// Set of nonexistent domain names
10    zones: HashSet<String>,
11}
12
13impl Default for NxZones {
14    fn default() -> Self {
15        Self::new()
16    }
17}
18
19impl NxZones {
20    /// Create a new empty NxZones
21    #[allow(dead_code)]
22    pub fn new() -> Self {
23        Self {
24            zones: HashSet::new(),
25        }
26    }
27
28    /// Load nonexistent zones from a file
29    ///
30    /// Each line in the file should contain a single domain name.
31    /// Empty lines and lines starting with '#' are ignored.
32    pub fn load_from_file<P: AsRef<Path>>(path: P) -> io::Result<Self> {
33        let file = File::open(path)?;
34        let reader = BufReader::new(file);
35        let mut zones = HashSet::new();
36
37        for line in reader.lines() {
38            let line = line?;
39            let trimmed = line.trim();
40
41            // Skip empty lines and comments
42            if trimmed.is_empty() || trimmed.starts_with('#') {
43                continue;
44            }
45
46            // Normalize domain name (lowercase, ensure it ends with a dot)
47            let mut domain = trimmed.to_lowercase();
48            if !domain.ends_with('.') {
49                domain.push('.');
50            }
51
52            zones.insert(domain);
53        }
54
55        Ok(Self { zones })
56    }
57
58    /// Check if a domain is in the nonexistent zones list
59    ///
60    /// A domain is considered nonexistent if it matches or is a subdomain of a nonexistent zone.
61    pub fn is_nonexistent(&self, domain: &str) -> bool {
62        // If no zones are defined, no domains are nonexistent
63        if self.zones.is_empty() {
64            return false;
65        }
66
67        // Normalize the domain (lowercase, ensure it ends with a dot)
68        let mut normalized = domain.to_lowercase();
69        if !normalized.ends_with('.') {
70            normalized.push('.');
71        }
72
73        // Check if the domain is in the nonexistent zones
74        if self.zones.contains(&normalized) {
75            return true;
76        }
77
78        // Check if the domain is a subdomain of a nonexistent zone
79        let mut parts: Vec<&str> = normalized.split('.').collect();
80
81        // Start removing subdomains one by one to check parent domains
82        while parts.len() > 2 {
83            // Keep at least "domain.tld."
84            parts.remove(0);
85            let parent = parts.join(".");
86            if self.zones.contains(&parent) {
87                return true;
88            }
89        }
90
91        false
92    }
93
94    /// Get the number of nonexistent zones
95    pub fn len(&self) -> usize {
96        self.zones.len()
97    }
98
99    /// Check if there are no nonexistent zones
100    #[allow(dead_code)]
101    pub fn is_empty(&self) -> bool {
102        self.zones.is_empty()
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::io::Write;
110    use tempfile::NamedTempFile;
111
112    #[test]
113    fn test_is_nonexistent_empty() {
114        let zones = NxZones::new();
115        assert!(!zones.is_nonexistent("example.com"));
116        assert!(!zones.is_nonexistent("test.example.com"));
117    }
118
119    #[test]
120    fn test_is_nonexistent_exact_match() {
121        let mut zones = NxZones::new();
122        zones.zones.insert("example.com.".to_string());
123
124        assert!(zones.is_nonexistent("example.com"));
125        assert!(zones.is_nonexistent("example.com."));
126        assert!(!zones.is_nonexistent("test.com"));
127    }
128
129    #[test]
130    fn test_is_nonexistent_subdomain() {
131        let mut zones = NxZones::new();
132        zones.zones.insert("example.com.".to_string());
133
134        assert!(zones.is_nonexistent("sub.example.com"));
135        assert!(zones.is_nonexistent("deep.sub.example.com"));
136        assert!(!zones.is_nonexistent("notexample.com"));
137    }
138
139    #[test]
140    fn test_load_from_file() -> io::Result<()> {
141        let mut file = NamedTempFile::new()?;
142        writeln!(file, "nonexistent.com")?;
143        writeln!(file, "# Comment line")?;
144        writeln!(file)?;
145        writeln!(file, "invalid.org.")?;
146
147        let zones = NxZones::load_from_file(file.path())?;
148
149        assert_eq!(zones.len(), 2);
150        assert!(zones.is_nonexistent("nonexistent.com"));
151        assert!(zones.is_nonexistent("sub.nonexistent.com"));
152        assert!(zones.is_nonexistent("invalid.org"));
153        assert!(!zones.is_nonexistent("other.com"));
154
155        Ok(())
156    }
157}