1use serde::{Deserialize, Serialize};
12
13use crate::domain_info::{DomainInfo, DomainInfoSource};
14use crate::lookup::LookupResult;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct FieldChange {
19 pub field: String,
21 pub old: Option<String>,
23 pub new: Option<String>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct DriftReport {
30 pub domain: String,
32 pub changes: Vec<FieldChange>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub inconclusive: Option<String>,
40}
41
42impl DriftReport {
43 pub fn has_drift(&self) -> bool {
46 !self.changes.is_empty()
47 }
48
49 pub fn empty(domain: &str) -> Self {
51 DriftReport {
52 domain: domain.to_string(),
53 changes: Vec::new(),
54 inconclusive: None,
55 }
56 }
57
58 pub fn between(domain: &str, old: &DomainInfo, new: &DomainInfo) -> Self {
68 let lapsed = |info: &DomainInfo| {
71 matches!(
72 info.availability_verdict.as_deref(),
73 Some("available" | "likely_available")
74 )
75 };
76 if has_registration_data(old) && !has_registration_data(new) && lapsed(new) {
77 return DriftReport {
78 domain: domain.to_string(),
79 changes: vec![FieldChange {
80 field: "registration".to_string(),
81 old: Some("registered".to_string()),
82 new: new.availability_verdict.clone(),
83 }],
84 inconclusive: None,
85 };
86 }
87 for (label, info) in [("previous", old), ("current", new)] {
88 if !has_registration_data(info) {
89 return DriftReport {
90 domain: domain.to_string(),
91 changes: Vec::new(),
92 inconclusive: Some(format!(
93 "the {label} lookup returned no registration data ({}), so the \
94 snapshots were not compared",
95 info.availability_verdict
96 .as_deref()
97 .unwrap_or("no RDAP/WHOIS data")
98 )),
99 };
100 }
101 }
102
103 let mut changes = Vec::new();
104
105 let mut push = |field: &str, old: Option<String>, new: Option<String>| {
106 if old != new {
107 changes.push(FieldChange {
108 field: field.to_string(),
109 old,
110 new,
111 });
112 }
113 };
114
115 for (field, o, n) in [
120 ("registrar", &old.registrar, &new.registrar),
121 ("organization", &old.organization, &new.organization),
122 ("registrant", &old.registrant, &new.registrant),
123 ] {
124 if let (Some(o), Some(n)) = (o, n) {
125 if contact_key(o) != contact_key(n) {
126 push(field, Some(o.clone()), Some(n.clone()));
127 }
128 }
129 }
130 push(
131 "nameservers",
132 joined_set(&old.nameservers),
133 joined_set(&new.nameservers),
134 );
135 push(
136 "status",
137 joined_keys(&old.status, status_key),
138 joined_keys(&new.status, status_key),
139 );
140 push(
141 "expiration_date",
142 old.expiration_date.map(|d| d.to_rfc3339()),
143 new.expiration_date.map(|d| d.to_rfc3339()),
144 );
145 if let (Some(o), Some(n)) = (dnssec_state(old), dnssec_state(new)) {
148 if o != n {
149 push("dnssec", old.dnssec.clone(), new.dnssec.clone());
150 }
151 }
152
153 DriftReport {
154 domain: domain.to_string(),
155 changes,
156 inconclusive: None,
157 }
158 }
159
160 pub fn from_lookups(domain: &str, previous: &LookupResult, current: &LookupResult) -> Self {
162 let old = DomainInfo::from_lookup_result(previous);
163 let new = DomainInfo::from_lookup_result(current);
164 Self::between(domain, &old, &new)
165 }
166}
167
168fn has_registration_data(info: &DomainInfo) -> bool {
172 info.source != DomainInfoSource::Available
173 || info.registrar.is_some()
174 || !info.nameservers.is_empty()
175 || !info.status.is_empty()
176 || info.expiration_date.is_some()
177}
178
179fn status_key(code: &str) -> String {
183 code.chars()
184 .take_while(|c| *c != '(')
185 .filter(|c| c.is_ascii_alphanumeric())
186 .map(|c| c.to_ascii_lowercase())
187 .collect()
188}
189
190fn contact_key(value: &str) -> String {
193 value
194 .split_whitespace()
195 .collect::<Vec<_>>()
196 .join(" ")
197 .to_lowercase()
198}
199
200fn dnssec_state(info: &DomainInfo) -> Option<bool> {
203 let v = info.dnssec.as_deref()?.trim().to_ascii_lowercase();
204 if v.starts_with("unsigned") || v == "no" || v == "false" || v == "inactive" {
205 Some(false)
206 } else if v.starts_with("signed") || v == "yes" || v == "true" || v == "active" {
207 Some(true)
208 } else {
209 None
210 }
211}
212
213fn joined_set(values: &[String]) -> Option<String> {
218 joined_keys(values, |v| v.trim().trim_end_matches('.').to_lowercase())
219}
220
221fn joined_keys(values: &[String], key: impl Fn(&str) -> String) -> Option<String> {
223 let mut normalized: Vec<String> = values
224 .iter()
225 .map(|v| key(v))
226 .filter(|v| !v.is_empty())
227 .collect();
228 normalized.sort();
229 normalized.dedup();
230 if normalized.is_empty() {
231 None
232 } else {
233 Some(normalized.join(", "))
234 }
235}
236
237pub fn is_comparable(result: &LookupResult) -> bool {
241 has_registration_data(&DomainInfo::from_lookup_result(result))
242}
243
244pub fn baseline_snapshot<'a>(
247 entries: impl DoubleEndedIterator<Item = &'a LookupResult> + Clone,
248) -> Option<&'a LookupResult> {
249 entries
250 .clone()
251 .rev()
252 .find(|r| is_comparable(r))
253 .or_else(|| entries.clone().next_back())
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use chrono::{TimeZone, Utc};
260
261 fn snapshot(registrar: &str, nameservers: &[&str], dnssec: &str) -> DomainInfo {
264 let whois = crate::whois::WhoisResponse {
265 domain: "example.com".to_string(),
266 registrar: Some(registrar.to_string()),
267 creation_date: Some(Utc.with_ymd_and_hms(2019, 6, 1, 0, 0, 0).unwrap()),
268 expiration_date: Some(Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap()),
269 nameservers: nameservers.iter().map(|s| s.to_string()).collect(),
270 dnssec: Some(dnssec.to_string()),
271 whois_server: "whois.example.com".to_string(),
272 ..Default::default()
273 };
274 DomainInfo::from_sources("example.com", None, Some(&whois))
275 }
276
277 #[test]
278 fn between_detects_nameserver_swap_and_registrar_transfer() {
279 let old = snapshot("Old Registrar", &["ns1.old.net", "ns2.old.net"], "signed");
280 let new = snapshot("New Registrar", &["ns1.new.net", "ns2.new.net"], "signed");
281 let report = DriftReport::between("example.com", &old, &new);
282
283 assert!(report.has_drift());
284 let fields: Vec<&str> = report.changes.iter().map(|c| c.field.as_str()).collect();
285 assert!(fields.contains(&"registrar"));
286 assert!(fields.contains(&"nameservers"));
287 assert!(!fields.contains(&"dnssec"), "dnssec unchanged");
288 }
289
290 #[test]
291 fn between_detects_dnssec_removal() {
292 let old = snapshot("R", &["ns1.example.com"], "signed");
293 let new = snapshot("R", &["ns1.example.com"], "unsigned");
294 let report = DriftReport::between("example.com", &old, &new);
295 let dnssec = report.changes.iter().find(|c| c.field == "dnssec").unwrap();
296 assert_eq!(dnssec.old.as_deref(), Some("signed"));
297 assert_eq!(dnssec.new.as_deref(), Some("unsigned"));
298 }
299
300 #[test]
301 fn nameserver_reordering_is_not_drift() {
302 let old = snapshot("R", &["NS1.example.com", "ns2.example.com"], "signed");
304 let new = snapshot("R", &["ns2.example.com", "ns1.EXAMPLE.com"], "signed");
305 let report = DriftReport::between("example.com", &old, &new);
306 assert!(
307 !report.has_drift(),
308 "reordering/case must not count as drift: {:?}",
309 report.changes
310 );
311 }
312
313 #[test]
314 fn rdap_whois_source_flip_is_not_drift() {
315 let mut old = snapshot("R", &["ns1.example.com."], "signed");
318 old.status = vec!["client transfer prohibited".to_string()];
319 let mut new = snapshot("R", &["NS1.EXAMPLE.COM"], "signedDelegation");
320 new.status = vec!["clientTransferProhibited".to_string()];
321 let report = DriftReport::between("example.com", &old, &new);
322 assert!(!report.has_drift(), "{:?}", report.changes);
323
324 let new = snapshot("R", &["ns1.example.com"], "Unsigned");
326 let report = DriftReport::between("example.com", &old, &new);
327 assert!(report.changes.iter().any(|c| c.field == "dnssec"));
328 }
329
330 #[test]
331 fn one_sided_contact_absence_is_not_drift() {
332 let mut old = snapshot("Example Registrar, Inc.", &["ns1.example.com"], "signed");
335 old.organization = Some("Example Org".to_string());
336 old.registrant = Some("Jane Doe".to_string());
337 let mut new = snapshot("R", &["ns1.example.com"], "signed");
338 new.registrar = None;
339 let report = DriftReport::between("example.com", &old, &new);
340 assert!(!report.has_drift(), "{:?}", report.changes);
341
342 let new = snapshot("EXAMPLE Registrar, Inc.", &["ns1.example.com"], "signed");
344 assert!(!DriftReport::between("example.com", &old, &new).has_drift());
345
346 let mut new = snapshot("Example Registrar, Inc.", &["ns1.example.com"], "signed");
348 new.registrant = Some("Mallory".to_string());
349 let report = DriftReport::between("example.com", &old, &new);
350 let change = report
351 .changes
352 .iter()
353 .find(|c| c.field == "registrant")
354 .expect("registrant change");
355 assert_eq!(change.old.as_deref(), Some("Jane Doe"));
356 assert_eq!(change.new.as_deref(), Some("Mallory"));
357 }
358
359 #[test]
360 fn a_snapshot_without_registration_data_is_inconclusive_not_drift() {
361 let old = snapshot("R", &["ns1.example.com"], "signed");
362 let mut empty = DomainInfo::from_sources("example.com", None, None);
364 empty.source = DomainInfoSource::Available;
365 empty.availability_verdict = Some("unknown".to_string());
366
367 let report = DriftReport::between("example.com", &old, &empty);
368 assert!(!report.has_drift(), "{:?}", report.changes);
369 assert!(report
370 .inconclusive
371 .as_deref()
372 .unwrap_or_default()
373 .contains("current lookup"));
374 let report = DriftReport::between("example.com", &empty, &old);
375 assert!(!report.has_drift());
376 assert!(report.inconclusive.is_some());
377 }
378
379 #[test]
380 fn a_lapsed_registration_is_drift() {
381 let old = snapshot("R", &["ns1.example.com"], "signed");
382 let mut gone = DomainInfo::from_sources("example.com", None, None);
383 gone.source = DomainInfoSource::Available;
384 gone.availability_verdict = Some("available".to_string());
385 let report = DriftReport::between("example.com", &old, &gone);
386 assert!(report.has_drift());
387 assert_eq!(report.changes[0].field, "registration");
388 assert_eq!(report.changes[0].new.as_deref(), Some("available"));
389 }
390
391 #[test]
392 fn identical_snapshots_have_no_drift() {
393 let a = snapshot("R", &["ns1.example.com"], "signed");
394 let b = snapshot("R", &["ns1.example.com"], "signed");
395 assert!(!DriftReport::between("example.com", &a, &b).has_drift());
396 }
397
398 #[test]
399 fn baseline_snapshot_needs_an_earlier_snapshot() {
400 let result = LookupResult::Whois {
401 data: crate::whois::WhoisResponse::parse(
402 "example.com",
403 "whois.example.com",
404 "Domain Name: example.com\nName Server: ns1.example.com\n",
405 ),
406 rdap_error: None,
407 rdap_fallback: None,
408 dns_nameservers: None,
409 };
410 assert!(baseline_snapshot(std::iter::empty()).is_none());
412 let previous = baseline_snapshot([&result].into_iter()).expect("baseline");
414 let report = DriftReport::from_lookups("example.com", previous, &result);
415 assert!(!report.has_drift());
416 }
417}