1use std::collections::HashSet;
2use std::fs::File;
3use std::io::{self, BufRead, BufReader};
4use std::path::Path;
5
6#[derive(Clone)]
8pub struct NxZones {
9 zones: HashSet<String>,
11}
12
13impl Default for NxZones {
14 fn default() -> Self {
15 Self::new()
16 }
17}
18
19impl NxZones {
20 #[allow(dead_code)]
22 pub fn new() -> Self {
23 Self {
24 zones: HashSet::new(),
25 }
26 }
27
28 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 if trimmed.is_empty() || trimmed.starts_with('#') {
43 continue;
44 }
45
46 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 pub fn is_nonexistent(&self, domain: &str) -> bool {
62 if self.zones.is_empty() {
64 return false;
65 }
66
67 let mut normalized = domain.to_lowercase();
69 if !normalized.ends_with('.') {
70 normalized.push('.');
71 }
72
73 if self.zones.contains(&normalized) {
75 return true;
76 }
77
78 let mut parts: Vec<&str> = normalized.split('.').collect();
80
81 while parts.len() > 2 {
83 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 pub fn len(&self) -> usize {
96 self.zones.len()
97 }
98
99 #[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}