use serde::{Deserialize, Serialize};
use crate::domain_info::DomainInfo;
use crate::history::LookupHistory;
use crate::lookup::LookupResult;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldChange {
pub field: String,
pub old: Option<String>,
pub new: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftReport {
pub domain: String,
pub changes: Vec<FieldChange>,
}
impl DriftReport {
pub fn has_drift(&self) -> bool {
!self.changes.is_empty()
}
pub fn between(domain: &str, old: &DomainInfo, new: &DomainInfo) -> Self {
let mut changes = Vec::new();
let mut push = |field: &str, old: Option<String>, new: Option<String>| {
if old != new {
changes.push(FieldChange {
field: field.to_string(),
old,
new,
});
}
};
push("registrar", old.registrar.clone(), new.registrar.clone());
push(
"organization",
old.organization.clone(),
new.organization.clone(),
);
push("registrant", old.registrant.clone(), new.registrant.clone());
push(
"nameservers",
joined_set(&old.nameservers),
joined_set(&new.nameservers),
);
push("status", joined_set(&old.status), joined_set(&new.status));
push(
"expiration_date",
old.expiration_date.map(|d| d.to_rfc3339()),
new.expiration_date.map(|d| d.to_rfc3339()),
);
push("dnssec", old.dnssec.clone(), new.dnssec.clone());
DriftReport {
domain: domain.to_string(),
changes,
}
}
pub fn from_lookups(domain: &str, previous: &LookupResult, current: &LookupResult) -> Self {
let old = DomainInfo::from_lookup_result(previous);
let new = DomainInfo::from_lookup_result(current);
Self::between(domain, &old, &new)
}
}
fn joined_set(values: &[String]) -> Option<String> {
let mut normalized: Vec<String> = values
.iter()
.map(|v| v.trim().to_lowercase())
.filter(|v| !v.is_empty())
.collect();
normalized.sort();
normalized.dedup();
if normalized.is_empty() {
None
} else {
Some(normalized.join(", "))
}
}
pub fn drift_from_history(history: &LookupHistory, domain: &str) -> Option<DriftReport> {
let entries = history.get(domain);
if entries.len() < 2 {
return None;
}
let previous = &entries[entries.len() - 2].result;
let current = &entries[entries.len() - 1].result;
Some(DriftReport::from_lookups(domain, previous, current))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
fn snapshot(registrar: &str, nameservers: &[&str], dnssec: &str) -> DomainInfo {
let whois = crate::whois::WhoisResponse {
domain: "example.com".to_string(),
registrar: Some(registrar.to_string()),
registrant: None,
organization: None,
registrant_email: None,
registrant_phone: None,
registrant_address: None,
registrant_country: None,
admin_name: None,
admin_organization: None,
admin_email: None,
admin_phone: None,
tech_name: None,
tech_organization: None,
tech_email: None,
tech_phone: None,
creation_date: Some(Utc.with_ymd_and_hms(2019, 6, 1, 0, 0, 0).unwrap()),
expiration_date: Some(Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap()),
updated_date: None,
nameservers: nameservers.iter().map(|s| s.to_string()).collect(),
status: vec![],
dnssec: Some(dnssec.to_string()),
whois_server: "whois.example.com".to_string(),
raw_response: String::new(),
};
DomainInfo::from_sources("example.com", None, Some(&whois))
}
#[test]
fn between_detects_nameserver_swap_and_registrar_transfer() {
let old = snapshot("Old Registrar", &["ns1.old.net", "ns2.old.net"], "signed");
let new = snapshot("New Registrar", &["ns1.new.net", "ns2.new.net"], "signed");
let report = DriftReport::between("example.com", &old, &new);
assert!(report.has_drift());
let fields: Vec<&str> = report.changes.iter().map(|c| c.field.as_str()).collect();
assert!(fields.contains(&"registrar"));
assert!(fields.contains(&"nameservers"));
assert!(!fields.contains(&"dnssec"), "dnssec unchanged");
}
#[test]
fn between_detects_dnssec_removal() {
let old = snapshot("R", &["ns1.example.com"], "signed");
let new = snapshot("R", &["ns1.example.com"], "unsigned");
let report = DriftReport::between("example.com", &old, &new);
let dnssec = report.changes.iter().find(|c| c.field == "dnssec").unwrap();
assert_eq!(dnssec.old.as_deref(), Some("signed"));
assert_eq!(dnssec.new.as_deref(), Some("unsigned"));
}
#[test]
fn nameserver_reordering_is_not_drift() {
let old = snapshot("R", &["NS1.example.com", "ns2.example.com"], "signed");
let new = snapshot("R", &["ns2.example.com", "ns1.EXAMPLE.com"], "signed");
let report = DriftReport::between("example.com", &old, &new);
assert!(
!report.has_drift(),
"reordering/case must not count as drift: {:?}",
report.changes
);
}
#[test]
fn identical_snapshots_have_no_drift() {
let a = snapshot("R", &["ns1.example.com"], "signed");
let b = snapshot("R", &["ns1.example.com"], "signed");
assert!(!DriftReport::between("example.com", &a, &b).has_drift());
}
#[test]
fn drift_from_history_needs_two_snapshots() {
let mut history = LookupHistory::default();
let result = LookupResult::Whois {
data: crate::whois::WhoisResponse::parse(
"example.com",
"whois.example.com",
"Domain Name: example.com\nName Server: ns1.example.com\n",
),
rdap_error: None,
rdap_fallback: None,
};
history.record("example.com", result.clone());
assert!(drift_from_history(&history, "example.com").is_none());
history.record("example.com", result);
let report = drift_from_history(&history, "example.com").expect("report");
assert!(!report.has_drift());
}
}