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 push("registrar", old.registrar.clone(), new.registrar.clone());
116 push(
117 "organization",
118 old.organization.clone(),
119 new.organization.clone(),
120 );
121 push("registrant", old.registrant.clone(), new.registrant.clone());
122 push(
123 "nameservers",
124 joined_set(&old.nameservers),
125 joined_set(&new.nameservers),
126 );
127 push(
128 "status",
129 joined_keys(&old.status, status_key),
130 joined_keys(&new.status, status_key),
131 );
132 push(
133 "expiration_date",
134 old.expiration_date.map(|d| d.to_rfc3339()),
135 new.expiration_date.map(|d| d.to_rfc3339()),
136 );
137 if let (Some(o), Some(n)) = (dnssec_state(old), dnssec_state(new)) {
140 if o != n {
141 push("dnssec", old.dnssec.clone(), new.dnssec.clone());
142 }
143 }
144
145 DriftReport {
146 domain: domain.to_string(),
147 changes,
148 inconclusive: None,
149 }
150 }
151
152 pub fn from_lookups(domain: &str, previous: &LookupResult, current: &LookupResult) -> Self {
154 let old = DomainInfo::from_lookup_result(previous);
155 let new = DomainInfo::from_lookup_result(current);
156 Self::between(domain, &old, &new)
157 }
158}
159
160fn has_registration_data(info: &DomainInfo) -> bool {
164 info.source != DomainInfoSource::Available
165 || info.registrar.is_some()
166 || !info.nameservers.is_empty()
167 || !info.status.is_empty()
168 || info.expiration_date.is_some()
169}
170
171fn status_key(code: &str) -> String {
175 code.chars()
176 .take_while(|c| *c != '(')
177 .filter(|c| c.is_ascii_alphanumeric())
178 .map(|c| c.to_ascii_lowercase())
179 .collect()
180}
181
182fn dnssec_state(info: &DomainInfo) -> Option<bool> {
185 let v = info.dnssec.as_deref()?.trim().to_ascii_lowercase();
186 if v.starts_with("unsigned") || v == "no" || v == "false" || v == "inactive" {
187 Some(false)
188 } else if v.starts_with("signed") || v == "yes" || v == "true" || v == "active" {
189 Some(true)
190 } else {
191 None
192 }
193}
194
195fn joined_set(values: &[String]) -> Option<String> {
200 joined_keys(values, |v| v.trim().trim_end_matches('.').to_lowercase())
201}
202
203fn joined_keys(values: &[String], key: impl Fn(&str) -> String) -> Option<String> {
205 let mut normalized: Vec<String> = values
206 .iter()
207 .map(|v| key(v))
208 .filter(|v| !v.is_empty())
209 .collect();
210 normalized.sort();
211 normalized.dedup();
212 if normalized.is_empty() {
213 None
214 } else {
215 Some(normalized.join(", "))
216 }
217}
218
219pub fn is_comparable(result: &LookupResult) -> bool {
223 has_registration_data(&DomainInfo::from_lookup_result(result))
224}
225
226pub fn baseline_snapshot<'a>(
229 entries: impl DoubleEndedIterator<Item = &'a LookupResult> + Clone,
230) -> Option<&'a LookupResult> {
231 entries
232 .clone()
233 .rev()
234 .find(|r| is_comparable(r))
235 .or_else(|| entries.clone().next_back())
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use chrono::{TimeZone, Utc};
242
243 fn snapshot(registrar: &str, nameservers: &[&str], dnssec: &str) -> DomainInfo {
246 let whois = crate::whois::WhoisResponse {
247 domain: "example.com".to_string(),
248 registrar: Some(registrar.to_string()),
249 creation_date: Some(Utc.with_ymd_and_hms(2019, 6, 1, 0, 0, 0).unwrap()),
250 expiration_date: Some(Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap()),
251 nameservers: nameservers.iter().map(|s| s.to_string()).collect(),
252 dnssec: Some(dnssec.to_string()),
253 whois_server: "whois.example.com".to_string(),
254 ..Default::default()
255 };
256 DomainInfo::from_sources("example.com", None, Some(&whois))
257 }
258
259 #[test]
260 fn between_detects_nameserver_swap_and_registrar_transfer() {
261 let old = snapshot("Old Registrar", &["ns1.old.net", "ns2.old.net"], "signed");
262 let new = snapshot("New Registrar", &["ns1.new.net", "ns2.new.net"], "signed");
263 let report = DriftReport::between("example.com", &old, &new);
264
265 assert!(report.has_drift());
266 let fields: Vec<&str> = report.changes.iter().map(|c| c.field.as_str()).collect();
267 assert!(fields.contains(&"registrar"));
268 assert!(fields.contains(&"nameservers"));
269 assert!(!fields.contains(&"dnssec"), "dnssec unchanged");
270 }
271
272 #[test]
273 fn between_detects_dnssec_removal() {
274 let old = snapshot("R", &["ns1.example.com"], "signed");
275 let new = snapshot("R", &["ns1.example.com"], "unsigned");
276 let report = DriftReport::between("example.com", &old, &new);
277 let dnssec = report.changes.iter().find(|c| c.field == "dnssec").unwrap();
278 assert_eq!(dnssec.old.as_deref(), Some("signed"));
279 assert_eq!(dnssec.new.as_deref(), Some("unsigned"));
280 }
281
282 #[test]
283 fn nameserver_reordering_is_not_drift() {
284 let old = snapshot("R", &["NS1.example.com", "ns2.example.com"], "signed");
286 let new = snapshot("R", &["ns2.example.com", "ns1.EXAMPLE.com"], "signed");
287 let report = DriftReport::between("example.com", &old, &new);
288 assert!(
289 !report.has_drift(),
290 "reordering/case must not count as drift: {:?}",
291 report.changes
292 );
293 }
294
295 #[test]
296 fn rdap_whois_source_flip_is_not_drift() {
297 let mut old = snapshot("R", &["ns1.example.com."], "signed");
300 old.status = vec!["client transfer prohibited".to_string()];
301 let mut new = snapshot("R", &["NS1.EXAMPLE.COM"], "signedDelegation");
302 new.status = vec!["clientTransferProhibited".to_string()];
303 let report = DriftReport::between("example.com", &old, &new);
304 assert!(!report.has_drift(), "{:?}", report.changes);
305
306 let new = snapshot("R", &["ns1.example.com"], "Unsigned");
308 let report = DriftReport::between("example.com", &old, &new);
309 assert!(report.changes.iter().any(|c| c.field == "dnssec"));
310 }
311
312 #[test]
313 fn a_snapshot_without_registration_data_is_inconclusive_not_drift() {
314 let old = snapshot("R", &["ns1.example.com"], "signed");
315 let mut empty = DomainInfo::from_sources("example.com", None, None);
317 empty.source = DomainInfoSource::Available;
318 empty.availability_verdict = Some("unknown".to_string());
319
320 let report = DriftReport::between("example.com", &old, &empty);
321 assert!(!report.has_drift(), "{:?}", report.changes);
322 assert!(report
323 .inconclusive
324 .as_deref()
325 .unwrap_or_default()
326 .contains("current lookup"));
327 let report = DriftReport::between("example.com", &empty, &old);
328 assert!(!report.has_drift());
329 assert!(report.inconclusive.is_some());
330 }
331
332 #[test]
333 fn a_lapsed_registration_is_drift() {
334 let old = snapshot("R", &["ns1.example.com"], "signed");
335 let mut gone = DomainInfo::from_sources("example.com", None, None);
336 gone.source = DomainInfoSource::Available;
337 gone.availability_verdict = Some("available".to_string());
338 let report = DriftReport::between("example.com", &old, &gone);
339 assert!(report.has_drift());
340 assert_eq!(report.changes[0].field, "registration");
341 assert_eq!(report.changes[0].new.as_deref(), Some("available"));
342 }
343
344 #[test]
345 fn identical_snapshots_have_no_drift() {
346 let a = snapshot("R", &["ns1.example.com"], "signed");
347 let b = snapshot("R", &["ns1.example.com"], "signed");
348 assert!(!DriftReport::between("example.com", &a, &b).has_drift());
349 }
350
351 #[test]
352 fn baseline_snapshot_needs_an_earlier_snapshot() {
353 let result = LookupResult::Whois {
354 data: crate::whois::WhoisResponse::parse(
355 "example.com",
356 "whois.example.com",
357 "Domain Name: example.com\nName Server: ns1.example.com\n",
358 ),
359 rdap_error: None,
360 rdap_fallback: None,
361 };
362 assert!(baseline_snapshot(std::iter::empty()).is_none());
364 let previous = baseline_snapshot([&result].into_iter()).expect("baseline");
366 let report = DriftReport::from_lookups("example.com", previous, &result);
367 assert!(!report.has_drift());
368 }
369}