1use serde::{Deserialize, Serialize};
2use tracing::{debug, instrument};
3
4use crate::dns::{DnsRecord, DnsResolver, RecordType};
5use crate::error::Result;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ServerResult {
10 pub nameserver: String,
11 pub records: Vec<DnsRecord>,
12 pub error: Option<String>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DnsComparison {
21 pub domain: String,
22 pub record_type: RecordType,
23 pub server_a: ServerResult,
24 pub server_b: ServerResult,
25 pub matches: bool,
26 pub only_in_a: Vec<String>,
27 pub only_in_b: Vec<String>,
28 pub common: Vec<String>,
29}
30
31pub struct DnsComparator {
36 resolver: DnsResolver,
37}
38
39impl Default for DnsComparator {
40 fn default() -> Self {
41 Self::new()
42 }
43}
44
45impl DnsComparator {
46 pub fn new() -> Self {
48 Self {
49 resolver: DnsResolver::new(),
50 }
51 }
52
53 #[instrument(skip(self), fields(domain = %domain, record_type = %record_type, server_a = %server_a, server_b = %server_b))]
65 pub async fn compare(
66 &self,
67 domain: &str,
68 record_type: RecordType,
69 server_a: &str,
70 server_b: &str,
71 ) -> Result<DnsComparison> {
72 let domain = crate::validation::normalize_domain(domain)?;
73
74 let (result_a, result_b) = tokio::join!(
76 self.resolver.resolve(&domain, record_type, Some(server_a)),
77 self.resolver.resolve(&domain, record_type, Some(server_b))
78 );
79
80 let server_a_result = match result_a {
81 Ok(records) => ServerResult {
82 nameserver: server_a.to_string(),
83 records,
84 error: None,
85 },
86 Err(e) => {
87 debug!(server = %server_a, error = %e, "DNS compare query failed");
88 ServerResult {
89 nameserver: server_a.to_string(),
90 records: vec![],
91 error: Some(e.sanitized_message()),
93 }
94 }
95 };
96
97 let server_b_result = match result_b {
98 Ok(records) => ServerResult {
99 nameserver: server_b.to_string(),
100 records,
101 error: None,
102 },
103 Err(e) => {
104 debug!(server = %server_b, error = %e, "DNS compare query failed");
105 ServerResult {
106 nameserver: server_b.to_string(),
107 records: vec![],
108 error: Some(e.sanitized_message()),
110 }
111 }
112 };
113
114 let (values_equal, only_in_a, only_in_b) =
119 compare_server_values(&server_a_result, &server_b_result);
120
121 let mut common = case_insensitive_common(&server_a_result, &server_b_result);
124 common.sort();
125
126 let matches =
127 values_equal && server_a_result.error.is_none() && server_b_result.error.is_none();
128
129 debug!(
130 matches = matches,
131 common = common.len(),
132 only_in_a = only_in_a.len(),
133 only_in_b = only_in_b.len(),
134 "DNS comparison complete"
135 );
136
137 Ok(DnsComparison {
138 domain: domain.to_string(),
139 record_type,
140 server_a: server_a_result,
141 server_b: server_b_result,
142 matches,
143 only_in_a,
144 only_in_b,
145 common,
146 })
147 }
148}
149
150fn compare_server_values(a: &ServerResult, b: &ServerResult) -> (bool, Vec<String>, Vec<String>) {
155 use std::collections::HashSet;
156
157 let keys_a: HashSet<String> = a
158 .records
159 .iter()
160 .map(|r| r.format_short().to_ascii_lowercase())
161 .collect();
162 let keys_b: HashSet<String> = b
163 .records
164 .iter()
165 .map(|r| r.format_short().to_ascii_lowercase())
166 .collect();
167
168 let mut only_in_a: Vec<String> = a
169 .records
170 .iter()
171 .filter(|r| !keys_b.contains(&r.format_short().to_ascii_lowercase()))
172 .map(|r| r.format_short())
173 .collect();
174 let mut only_in_b: Vec<String> = b
175 .records
176 .iter()
177 .filter(|r| !keys_a.contains(&r.format_short().to_ascii_lowercase()))
178 .map(|r| r.format_short())
179 .collect();
180 only_in_a.sort();
181 only_in_a.dedup();
182 only_in_b.sort();
183 only_in_b.dedup();
184
185 let values_equal = only_in_a.is_empty() && only_in_b.is_empty();
186 (values_equal, only_in_a, only_in_b)
187}
188
189fn case_insensitive_common(a: &ServerResult, b: &ServerResult) -> Vec<String> {
192 use std::collections::HashSet;
193 let keys_b: HashSet<String> = b
194 .records
195 .iter()
196 .map(|r| r.format_short().to_ascii_lowercase())
197 .collect();
198 let mut seen: HashSet<String> = HashSet::new();
199 let mut common = Vec::new();
200 for r in &a.records {
201 let key = r.format_short().to_ascii_lowercase();
202 if keys_b.contains(&key) && seen.insert(key) {
203 common.push(r.format_short());
204 }
205 }
206 common
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::dns::{RecordData, RecordType};
213
214 #[test]
215 fn test_dns_comparison_serialization() {
216 let comparison = DnsComparison {
217 domain: "example.com".to_string(),
218 record_type: RecordType::A,
219 server_a: ServerResult {
220 nameserver: "8.8.8.8".to_string(),
221 records: vec![DnsRecord {
222 name: "example.com".to_string(),
223 record_type: RecordType::A,
224 ttl: 300,
225 data: RecordData::A {
226 address: "93.184.216.34".to_string(),
227 },
228 }],
229 error: None,
230 },
231 server_b: ServerResult {
232 nameserver: "1.1.1.1".to_string(),
233 records: vec![DnsRecord {
234 name: "example.com".to_string(),
235 record_type: RecordType::A,
236 ttl: 300,
237 data: RecordData::A {
238 address: "93.184.216.34".to_string(),
239 },
240 }],
241 error: None,
242 },
243 matches: true,
244 only_in_a: vec![],
245 only_in_b: vec![],
246 common: vec!["93.184.216.34".to_string()],
247 };
248
249 let json = serde_json::to_string(&comparison).unwrap();
250 assert!(json.contains("example.com"));
251 assert!(json.contains("93.184.216.34"));
252 assert!(json.contains("\"matches\":true"));
253 }
254
255 #[test]
256 fn test_server_result_with_error() {
257 let result = ServerResult {
258 nameserver: "8.8.8.8".to_string(),
259 records: vec![],
260 error: Some("connection timed out".to_string()),
261 };
262
263 let json = serde_json::to_string(&result).unwrap();
264 assert!(json.contains("connection timed out"));
265 }
266
267 #[test]
272 fn compare_values_case_insensitive_match() {
273 let upper = ServerResult {
274 nameserver: "8.8.8.8".to_string(),
275 records: vec![DnsRecord {
276 name: "example.com".to_string(),
277 record_type: RecordType::NS,
278 ttl: 300,
279 data: RecordData::NS {
280 nameserver: "NS1.EXAMPLE.COM.".to_string(),
281 },
282 }],
283 error: None,
284 };
285 let lower = ServerResult {
286 nameserver: "1.1.1.1".to_string(),
287 records: vec![DnsRecord {
288 name: "example.com".to_string(),
289 record_type: RecordType::NS,
290 ttl: 300,
291 data: RecordData::NS {
292 nameserver: "ns1.example.com.".to_string(),
293 },
294 }],
295 error: None,
296 };
297
298 let (matches, only_in_a, only_in_b) = compare_server_values(&upper, &lower);
299 assert!(matches, "case-only difference must be a match");
300 assert!(
301 only_in_a.is_empty(),
302 "no records unique to A: {only_in_a:?}"
303 );
304 assert!(
305 only_in_b.is_empty(),
306 "no records unique to B: {only_in_b:?}"
307 );
308 }
309}