Skip to main content

seer_core/dns/
compare.rs

1use serde::{Deserialize, Serialize};
2use tracing::{debug, instrument};
3
4use crate::dns::{DnsRecord, DnsResolver, RecordType};
5use crate::error::Result;
6
7/// Result of querying DNS records from a single nameserver.
8#[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/// Comparison of DNS records between two nameservers.
16///
17/// Contains the records from each server, whether they match,
18/// and the set differences (only_in_a, only_in_b, common).
19#[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
31/// Compares DNS records for a domain across two nameservers.
32///
33/// Queries both servers concurrently and produces a structured
34/// comparison showing common records, differences, and errors.
35pub struct DnsComparator {
36    resolver: DnsResolver,
37}
38
39impl Default for DnsComparator {
40    fn default() -> Self {
41        Self::new()
42    }
43}
44
45impl DnsComparator {
46    /// Creates a new DNS comparator with default resolver settings.
47    pub fn new() -> Self {
48        Self {
49            resolver: DnsResolver::new(),
50        }
51    }
52
53    /// Compares DNS records for a domain between two nameservers.
54    ///
55    /// # Arguments
56    /// * `domain` - The domain name to query
57    /// * `record_type` - The type of DNS record to compare (A, AAAA, MX, etc.)
58    /// * `server_a` - IP address of the first nameserver
59    /// * `server_b` - IP address of the second nameserver
60    ///
61    /// # Returns
62    /// A `DnsComparison` showing records from each server, whether they match,
63    /// and which records are unique to each server or shared.
64    #[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        // Query both servers concurrently
75        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                    // Sanitized for external return; full detail logged above.
92                    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                    // Sanitized for external return; full detail logged above.
109                    error: Some(e.sanitized_message()),
110                }
111            }
112        };
113
114        // Compare record values case-insensitively. Two servers returning the
115        // same record with different casing (common with 0x20 query-name
116        // randomization, e.g. `NS1.EXAMPLE.COM.` vs `ns1.example.com.`) must be
117        // treated as equal, not a spurious mismatch.
118        let (values_equal, only_in_a, only_in_b) =
119            compare_server_values(&server_a_result, &server_b_result);
120
121        // `common` is informational; build it case-insensitively too but emit
122        // the original-cased values from server A for display.
123        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
150/// Compares the record sets of two servers case-insensitively, returning
151/// `(values_equal, only_in_a, only_in_b)`. Each `only_in_*` entry is the
152/// original-cased `format_short()` value so display preserves what the server
153/// actually returned, but membership is decided on the lowercased key.
154fn 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
189/// Returns the values present (case-insensitively) on both servers, emitting
190/// server A's original casing for each shared value.
191fn 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    /// Two servers returning the same NS record with different casing (common
268    /// with 0x20 query-name randomization) must be treated as a match, not a
269    /// mismatch. The comparison key is case-folded so `NS1.EXAMPLE.COM.` and
270    /// `ns1.example.com.` are equal.
271    #[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}