1use serde::{Deserialize, Serialize};
12
13use crate::domain_info::DomainInfo;
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}
36
37impl DriftReport {
38 pub fn has_drift(&self) -> bool {
41 !self.changes.is_empty()
42 }
43
44 pub fn between(domain: &str, old: &DomainInfo, new: &DomainInfo) -> Self {
47 let mut changes = Vec::new();
48
49 let mut push = |field: &str, old: Option<String>, new: Option<String>| {
50 if old != new {
51 changes.push(FieldChange {
52 field: field.to_string(),
53 old,
54 new,
55 });
56 }
57 };
58
59 push("registrar", old.registrar.clone(), new.registrar.clone());
60 push(
61 "organization",
62 old.organization.clone(),
63 new.organization.clone(),
64 );
65 push("registrant", old.registrant.clone(), new.registrant.clone());
66 push(
67 "nameservers",
68 joined_set(&old.nameservers),
69 joined_set(&new.nameservers),
70 );
71 push("status", joined_set(&old.status), joined_set(&new.status));
72 push(
73 "expiration_date",
74 old.expiration_date.map(|d| d.to_rfc3339()),
75 new.expiration_date.map(|d| d.to_rfc3339()),
76 );
77 push("dnssec", old.dnssec.clone(), new.dnssec.clone());
78
79 DriftReport {
80 domain: domain.to_string(),
81 changes,
82 }
83 }
84
85 pub fn from_lookups(domain: &str, previous: &LookupResult, current: &LookupResult) -> Self {
87 let old = DomainInfo::from_lookup_result(previous);
88 let new = DomainInfo::from_lookup_result(current);
89 Self::between(domain, &old, &new)
90 }
91}
92
93fn joined_set(values: &[String]) -> Option<String> {
98 let mut normalized: Vec<String> = values
99 .iter()
100 .map(|v| v.trim().to_lowercase())
101 .filter(|v| !v.is_empty())
102 .collect();
103 normalized.sort();
104 normalized.dedup();
105 if normalized.is_empty() {
106 None
107 } else {
108 Some(normalized.join(", "))
109 }
110}
111
112pub fn drift_from_history(history: &LookupHistory, domain: &str) -> Option<DriftReport> {
117 let entries = history.get(domain);
118 if entries.len() < 2 {
119 return None;
120 }
121 let previous = &entries[entries.len() - 2].result;
122 let current = &entries[entries.len() - 1].result;
123 Some(DriftReport::from_lookups(domain, previous, current))
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use chrono::{TimeZone, Utc};
130
131 fn snapshot(registrar: &str, nameservers: &[&str], dnssec: &str) -> DomainInfo {
134 let whois = crate::whois::WhoisResponse {
135 domain: "example.com".to_string(),
136 registrar: Some(registrar.to_string()),
137 registrant: None,
138 organization: None,
139 registrant_email: None,
140 registrant_phone: None,
141 registrant_address: None,
142 registrant_country: None,
143 admin_name: None,
144 admin_organization: None,
145 admin_email: None,
146 admin_phone: None,
147 tech_name: None,
148 tech_organization: None,
149 tech_email: None,
150 tech_phone: None,
151 creation_date: Some(Utc.with_ymd_and_hms(2019, 6, 1, 0, 0, 0).unwrap()),
152 expiration_date: Some(Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap()),
153 updated_date: None,
154 nameservers: nameservers.iter().map(|s| s.to_string()).collect(),
155 status: vec![],
156 dnssec: Some(dnssec.to_string()),
157 whois_server: "whois.example.com".to_string(),
158 raw_response: String::new(),
159 };
160 DomainInfo::from_sources("example.com", None, Some(&whois))
161 }
162
163 #[test]
164 fn between_detects_nameserver_swap_and_registrar_transfer() {
165 let old = snapshot("Old Registrar", &["ns1.old.net", "ns2.old.net"], "signed");
166 let new = snapshot("New Registrar", &["ns1.new.net", "ns2.new.net"], "signed");
167 let report = DriftReport::between("example.com", &old, &new);
168
169 assert!(report.has_drift());
170 let fields: Vec<&str> = report.changes.iter().map(|c| c.field.as_str()).collect();
171 assert!(fields.contains(&"registrar"));
172 assert!(fields.contains(&"nameservers"));
173 assert!(!fields.contains(&"dnssec"), "dnssec unchanged");
174 }
175
176 #[test]
177 fn between_detects_dnssec_removal() {
178 let old = snapshot("R", &["ns1.example.com"], "signed");
179 let new = snapshot("R", &["ns1.example.com"], "unsigned");
180 let report = DriftReport::between("example.com", &old, &new);
181 let dnssec = report.changes.iter().find(|c| c.field == "dnssec").unwrap();
182 assert_eq!(dnssec.old.as_deref(), Some("signed"));
183 assert_eq!(dnssec.new.as_deref(), Some("unsigned"));
184 }
185
186 #[test]
187 fn nameserver_reordering_is_not_drift() {
188 let old = snapshot("R", &["NS1.example.com", "ns2.example.com"], "signed");
190 let new = snapshot("R", &["ns2.example.com", "ns1.EXAMPLE.com"], "signed");
191 let report = DriftReport::between("example.com", &old, &new);
192 assert!(
193 !report.has_drift(),
194 "reordering/case must not count as drift: {:?}",
195 report.changes
196 );
197 }
198
199 #[test]
200 fn identical_snapshots_have_no_drift() {
201 let a = snapshot("R", &["ns1.example.com"], "signed");
202 let b = snapshot("R", &["ns1.example.com"], "signed");
203 assert!(!DriftReport::between("example.com", &a, &b).has_drift());
204 }
205
206 #[test]
207 fn drift_from_history_needs_two_snapshots() {
208 let mut history = LookupHistory::default();
209 let result = LookupResult::Whois {
210 data: crate::whois::WhoisResponse::parse(
211 "example.com",
212 "whois.example.com",
213 "Domain Name: example.com\nName Server: ns1.example.com\n",
214 ),
215 rdap_error: None,
216 rdap_fallback: None,
217 };
218 history.record("example.com", result.clone());
219 assert!(drift_from_history(&history, "example.com").is_none());
221 history.record("example.com", result);
222 let report = drift_from_history(&history, "example.com").expect("report");
224 assert!(!report.has_drift());
225 }
226}