1use serde::{Deserialize, Serialize};
12
13use crate::domain_info::{DomainInfo, DomainInfoSource};
14use crate::history::LookupHistory;
15use crate::lookup::LookupResult;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct FieldChange {
20 pub field: String,
22 pub old: Option<String>,
24 pub new: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DriftReport {
31 pub domain: String,
33 pub changes: Vec<FieldChange>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub inconclusive: Option<String>,
41}
42
43impl DriftReport {
44 pub fn has_drift(&self) -> bool {
47 !self.changes.is_empty()
48 }
49
50 pub fn empty(domain: &str) -> Self {
52 DriftReport {
53 domain: domain.to_string(),
54 changes: Vec::new(),
55 inconclusive: None,
56 }
57 }
58
59 pub fn between(domain: &str, old: &DomainInfo, new: &DomainInfo) -> Self {
69 let lapsed = |info: &DomainInfo| {
72 matches!(
73 info.availability_verdict.as_deref(),
74 Some("available" | "likely_available")
75 )
76 };
77 if has_registration_data(old) && !has_registration_data(new) && lapsed(new) {
78 return DriftReport {
79 domain: domain.to_string(),
80 changes: vec![FieldChange {
81 field: "registration".to_string(),
82 old: Some("registered".to_string()),
83 new: new.availability_verdict.clone(),
84 }],
85 inconclusive: None,
86 };
87 }
88 for (label, info) in [("previous", old), ("current", new)] {
89 if !has_registration_data(info) {
90 return DriftReport {
91 domain: domain.to_string(),
92 changes: Vec::new(),
93 inconclusive: Some(format!(
94 "the {label} lookup returned no registration data ({}), so the \
95 snapshots were not compared",
96 info.availability_verdict
97 .as_deref()
98 .unwrap_or("no RDAP/WHOIS data")
99 )),
100 };
101 }
102 }
103
104 let mut changes = Vec::new();
105
106 let mut push = |field: &str, old: Option<String>, new: Option<String>| {
107 if old != new {
108 changes.push(FieldChange {
109 field: field.to_string(),
110 old,
111 new,
112 });
113 }
114 };
115
116 push("registrar", old.registrar.clone(), new.registrar.clone());
117 push(
118 "organization",
119 old.organization.clone(),
120 new.organization.clone(),
121 );
122 push("registrant", old.registrant.clone(), new.registrant.clone());
123 push(
124 "nameservers",
125 joined_set(&old.nameservers),
126 joined_set(&new.nameservers),
127 );
128 push(
129 "status",
130 joined_keys(&old.status, status_key),
131 joined_keys(&new.status, status_key),
132 );
133 push(
134 "expiration_date",
135 old.expiration_date.map(|d| d.to_rfc3339()),
136 new.expiration_date.map(|d| d.to_rfc3339()),
137 );
138 if let (Some(o), Some(n)) = (dnssec_state(old), dnssec_state(new)) {
141 if o != n {
142 push("dnssec", old.dnssec.clone(), new.dnssec.clone());
143 }
144 }
145
146 DriftReport {
147 domain: domain.to_string(),
148 changes,
149 inconclusive: None,
150 }
151 }
152
153 pub fn from_lookups(domain: &str, previous: &LookupResult, current: &LookupResult) -> Self {
155 let old = DomainInfo::from_lookup_result(previous);
156 let new = DomainInfo::from_lookup_result(current);
157 Self::between(domain, &old, &new)
158 }
159}
160
161fn has_registration_data(info: &DomainInfo) -> bool {
165 info.source != DomainInfoSource::Available
166 || info.registrar.is_some()
167 || !info.nameservers.is_empty()
168 || !info.status.is_empty()
169 || info.expiration_date.is_some()
170}
171
172fn status_key(code: &str) -> String {
176 code.chars()
177 .take_while(|c| *c != '(')
178 .filter(|c| c.is_ascii_alphanumeric())
179 .map(|c| c.to_ascii_lowercase())
180 .collect()
181}
182
183fn dnssec_state(info: &DomainInfo) -> Option<bool> {
186 let v = info.dnssec.as_deref()?.trim().to_ascii_lowercase();
187 if v.starts_with("unsigned") || v == "no" || v == "false" || v == "inactive" {
188 Some(false)
189 } else if v.starts_with("signed") || v == "yes" || v == "true" || v == "active" {
190 Some(true)
191 } else {
192 None
193 }
194}
195
196fn joined_set(values: &[String]) -> Option<String> {
201 joined_keys(values, |v| v.trim().trim_end_matches('.').to_lowercase())
202}
203
204fn joined_keys(values: &[String], key: impl Fn(&str) -> String) -> Option<String> {
206 let mut normalized: Vec<String> = values
207 .iter()
208 .map(|v| key(v))
209 .filter(|v| !v.is_empty())
210 .collect();
211 normalized.sort();
212 normalized.dedup();
213 if normalized.is_empty() {
214 None
215 } else {
216 Some(normalized.join(", "))
217 }
218}
219
220pub fn is_comparable(result: &LookupResult) -> bool {
224 has_registration_data(&DomainInfo::from_lookup_result(result))
225}
226
227pub fn baseline_snapshot<'a>(
230 entries: impl DoubleEndedIterator<Item = &'a LookupResult> + Clone,
231) -> Option<&'a LookupResult> {
232 entries
233 .clone()
234 .rev()
235 .find(|r| is_comparable(r))
236 .or_else(|| entries.clone().next_back())
237}
238
239pub fn drift_from_history(history: &LookupHistory, domain: &str) -> Option<DriftReport> {
245 let entries = history.get(domain);
246 let (current, earlier) = entries.split_last()?;
247 let previous = baseline_snapshot(earlier.iter().map(|e| &e.result))?;
248 Some(DriftReport::from_lookups(domain, previous, ¤t.result))
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use chrono::{TimeZone, Utc};
255
256 fn snapshot(registrar: &str, nameservers: &[&str], dnssec: &str) -> DomainInfo {
259 let whois = crate::whois::WhoisResponse {
260 domain: "example.com".to_string(),
261 registrar: Some(registrar.to_string()),
262 registrant: None,
263 organization: None,
264 registrant_email: None,
265 registrant_phone: None,
266 registrant_address: None,
267 registrant_country: None,
268 admin_name: None,
269 admin_organization: None,
270 admin_email: None,
271 admin_phone: None,
272 tech_name: None,
273 tech_organization: None,
274 tech_email: None,
275 tech_phone: None,
276 creation_date: Some(Utc.with_ymd_and_hms(2019, 6, 1, 0, 0, 0).unwrap()),
277 expiration_date: Some(Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap()),
278 updated_date: None,
279 nameservers: nameservers.iter().map(|s| s.to_string()).collect(),
280 status: vec![],
281 dnssec: Some(dnssec.to_string()),
282 whois_server: "whois.example.com".to_string(),
283 raw_response: String::new(),
284 };
285 DomainInfo::from_sources("example.com", None, Some(&whois))
286 }
287
288 #[test]
289 fn between_detects_nameserver_swap_and_registrar_transfer() {
290 let old = snapshot("Old Registrar", &["ns1.old.net", "ns2.old.net"], "signed");
291 let new = snapshot("New Registrar", &["ns1.new.net", "ns2.new.net"], "signed");
292 let report = DriftReport::between("example.com", &old, &new);
293
294 assert!(report.has_drift());
295 let fields: Vec<&str> = report.changes.iter().map(|c| c.field.as_str()).collect();
296 assert!(fields.contains(&"registrar"));
297 assert!(fields.contains(&"nameservers"));
298 assert!(!fields.contains(&"dnssec"), "dnssec unchanged");
299 }
300
301 #[test]
302 fn between_detects_dnssec_removal() {
303 let old = snapshot("R", &["ns1.example.com"], "signed");
304 let new = snapshot("R", &["ns1.example.com"], "unsigned");
305 let report = DriftReport::between("example.com", &old, &new);
306 let dnssec = report.changes.iter().find(|c| c.field == "dnssec").unwrap();
307 assert_eq!(dnssec.old.as_deref(), Some("signed"));
308 assert_eq!(dnssec.new.as_deref(), Some("unsigned"));
309 }
310
311 #[test]
312 fn nameserver_reordering_is_not_drift() {
313 let old = snapshot("R", &["NS1.example.com", "ns2.example.com"], "signed");
315 let new = snapshot("R", &["ns2.example.com", "ns1.EXAMPLE.com"], "signed");
316 let report = DriftReport::between("example.com", &old, &new);
317 assert!(
318 !report.has_drift(),
319 "reordering/case must not count as drift: {:?}",
320 report.changes
321 );
322 }
323
324 #[test]
325 fn rdap_whois_source_flip_is_not_drift() {
326 let mut old = snapshot("R", &["ns1.example.com."], "signed");
329 old.status = vec!["client transfer prohibited".to_string()];
330 let mut new = snapshot("R", &["NS1.EXAMPLE.COM"], "signedDelegation");
331 new.status = vec!["clientTransferProhibited".to_string()];
332 let report = DriftReport::between("example.com", &old, &new);
333 assert!(!report.has_drift(), "{:?}", report.changes);
334
335 let new = snapshot("R", &["ns1.example.com"], "Unsigned");
337 let report = DriftReport::between("example.com", &old, &new);
338 assert!(report.changes.iter().any(|c| c.field == "dnssec"));
339 }
340
341 #[test]
342 fn a_snapshot_without_registration_data_is_inconclusive_not_drift() {
343 let old = snapshot("R", &["ns1.example.com"], "signed");
344 let mut empty = DomainInfo::from_sources("example.com", None, None);
346 empty.source = DomainInfoSource::Available;
347 empty.availability_verdict = Some("unknown".to_string());
348
349 let report = DriftReport::between("example.com", &old, &empty);
350 assert!(!report.has_drift(), "{:?}", report.changes);
351 assert!(report
352 .inconclusive
353 .as_deref()
354 .unwrap_or_default()
355 .contains("current lookup"));
356 let report = DriftReport::between("example.com", &empty, &old);
357 assert!(!report.has_drift());
358 assert!(report.inconclusive.is_some());
359 }
360
361 #[test]
362 fn a_lapsed_registration_is_drift() {
363 let old = snapshot("R", &["ns1.example.com"], "signed");
364 let mut gone = DomainInfo::from_sources("example.com", None, None);
365 gone.source = DomainInfoSource::Available;
366 gone.availability_verdict = Some("available".to_string());
367 let report = DriftReport::between("example.com", &old, &gone);
368 assert!(report.has_drift());
369 assert_eq!(report.changes[0].field, "registration");
370 assert_eq!(report.changes[0].new.as_deref(), Some("available"));
371 }
372
373 #[test]
374 fn identical_snapshots_have_no_drift() {
375 let a = snapshot("R", &["ns1.example.com"], "signed");
376 let b = snapshot("R", &["ns1.example.com"], "signed");
377 assert!(!DriftReport::between("example.com", &a, &b).has_drift());
378 }
379
380 #[test]
381 fn drift_from_history_needs_two_snapshots() {
382 let mut history = LookupHistory::default();
383 let result = LookupResult::Whois {
384 data: crate::whois::WhoisResponse::parse(
385 "example.com",
386 "whois.example.com",
387 "Domain Name: example.com\nName Server: ns1.example.com\n",
388 ),
389 rdap_error: None,
390 rdap_fallback: None,
391 };
392 history.record("example.com", result.clone());
393 assert!(drift_from_history(&history, "example.com").is_none());
395 history.record("example.com", result);
396 let report = drift_from_history(&history, "example.com").expect("report");
398 assert!(!report.has_drift());
399 }
400}