use serde::{Deserialize, Serialize};
use crate::domain_info::{DomainInfo, DomainInfoSource};
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inconclusive: Option<String>,
}
impl DriftReport {
pub fn has_drift(&self) -> bool {
!self.changes.is_empty()
}
pub fn empty(domain: &str) -> Self {
DriftReport {
domain: domain.to_string(),
changes: Vec::new(),
inconclusive: None,
}
}
pub fn between(domain: &str, old: &DomainInfo, new: &DomainInfo) -> Self {
let lapsed = |info: &DomainInfo| {
matches!(
info.availability_verdict.as_deref(),
Some("available" | "likely_available")
)
};
if has_registration_data(old) && !has_registration_data(new) && lapsed(new) {
return DriftReport {
domain: domain.to_string(),
changes: vec![FieldChange {
field: "registration".to_string(),
old: Some("registered".to_string()),
new: new.availability_verdict.clone(),
}],
inconclusive: None,
};
}
for (label, info) in [("previous", old), ("current", new)] {
if !has_registration_data(info) {
return DriftReport {
domain: domain.to_string(),
changes: Vec::new(),
inconclusive: Some(format!(
"the {label} lookup returned no registration data ({}), so the \
snapshots were not compared",
info.availability_verdict
.as_deref()
.unwrap_or("no RDAP/WHOIS data")
)),
};
}
}
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,
});
}
};
for (field, o, n) in [
("registrar", &old.registrar, &new.registrar),
("organization", &old.organization, &new.organization),
("registrant", &old.registrant, &new.registrant),
] {
if let (Some(o), Some(n)) = (o, n) {
if contact_key(o) != contact_key(n) {
push(field, Some(o.clone()), Some(n.clone()));
}
}
}
push(
"nameservers",
joined_set(&old.nameservers),
joined_set(&new.nameservers),
);
push(
"status",
joined_keys(&old.status, status_key),
joined_keys(&new.status, status_key),
);
push(
"expiration_date",
old.expiration_date.map(|d| d.to_rfc3339()),
new.expiration_date.map(|d| d.to_rfc3339()),
);
if let (Some(o), Some(n)) = (dnssec_state(old), dnssec_state(new)) {
if o != n {
push("dnssec", old.dnssec.clone(), new.dnssec.clone());
}
}
DriftReport {
domain: domain.to_string(),
changes,
inconclusive: None,
}
}
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 has_registration_data(info: &DomainInfo) -> bool {
info.source != DomainInfoSource::Available
|| info.registrar.is_some()
|| !info.nameservers.is_empty()
|| !info.status.is_empty()
|| info.expiration_date.is_some()
}
fn status_key(code: &str) -> String {
code.chars()
.take_while(|c| *c != '(')
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
fn contact_key(value: &str) -> String {
value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
fn dnssec_state(info: &DomainInfo) -> Option<bool> {
let v = info.dnssec.as_deref()?.trim().to_ascii_lowercase();
if v.starts_with("unsigned") || v == "no" || v == "false" || v == "inactive" {
Some(false)
} else if v.starts_with("signed") || v == "yes" || v == "true" || v == "active" {
Some(true)
} else {
None
}
}
fn joined_set(values: &[String]) -> Option<String> {
joined_keys(values, |v| v.trim().trim_end_matches('.').to_lowercase())
}
fn joined_keys(values: &[String], key: impl Fn(&str) -> String) -> Option<String> {
let mut normalized: Vec<String> = values
.iter()
.map(|v| key(v))
.filter(|v| !v.is_empty())
.collect();
normalized.sort();
normalized.dedup();
if normalized.is_empty() {
None
} else {
Some(normalized.join(", "))
}
}
pub fn is_comparable(result: &LookupResult) -> bool {
has_registration_data(&DomainInfo::from_lookup_result(result))
}
pub fn baseline_snapshot<'a>(
entries: impl DoubleEndedIterator<Item = &'a LookupResult> + Clone,
) -> Option<&'a LookupResult> {
entries
.clone()
.rev()
.find(|r| is_comparable(r))
.or_else(|| entries.clone().next_back())
}
#[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()),
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()),
nameservers: nameservers.iter().map(|s| s.to_string()).collect(),
dnssec: Some(dnssec.to_string()),
whois_server: "whois.example.com".to_string(),
..Default::default()
};
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 rdap_whois_source_flip_is_not_drift() {
let mut old = snapshot("R", &["ns1.example.com."], "signed");
old.status = vec!["client transfer prohibited".to_string()];
let mut new = snapshot("R", &["NS1.EXAMPLE.COM"], "signedDelegation");
new.status = vec!["clientTransferProhibited".to_string()];
let report = DriftReport::between("example.com", &old, &new);
assert!(!report.has_drift(), "{:?}", report.changes);
let new = snapshot("R", &["ns1.example.com"], "Unsigned");
let report = DriftReport::between("example.com", &old, &new);
assert!(report.changes.iter().any(|c| c.field == "dnssec"));
}
#[test]
fn one_sided_contact_absence_is_not_drift() {
let mut old = snapshot("Example Registrar, Inc.", &["ns1.example.com"], "signed");
old.organization = Some("Example Org".to_string());
old.registrant = Some("Jane Doe".to_string());
let mut new = snapshot("R", &["ns1.example.com"], "signed");
new.registrar = None;
let report = DriftReport::between("example.com", &old, &new);
assert!(!report.has_drift(), "{:?}", report.changes);
let new = snapshot("EXAMPLE Registrar, Inc.", &["ns1.example.com"], "signed");
assert!(!DriftReport::between("example.com", &old, &new).has_drift());
let mut new = snapshot("Example Registrar, Inc.", &["ns1.example.com"], "signed");
new.registrant = Some("Mallory".to_string());
let report = DriftReport::between("example.com", &old, &new);
let change = report
.changes
.iter()
.find(|c| c.field == "registrant")
.expect("registrant change");
assert_eq!(change.old.as_deref(), Some("Jane Doe"));
assert_eq!(change.new.as_deref(), Some("Mallory"));
}
#[test]
fn a_snapshot_without_registration_data_is_inconclusive_not_drift() {
let old = snapshot("R", &["ns1.example.com"], "signed");
let mut empty = DomainInfo::from_sources("example.com", None, None);
empty.source = DomainInfoSource::Available;
empty.availability_verdict = Some("unknown".to_string());
let report = DriftReport::between("example.com", &old, &empty);
assert!(!report.has_drift(), "{:?}", report.changes);
assert!(report
.inconclusive
.as_deref()
.unwrap_or_default()
.contains("current lookup"));
let report = DriftReport::between("example.com", &empty, &old);
assert!(!report.has_drift());
assert!(report.inconclusive.is_some());
}
#[test]
fn a_lapsed_registration_is_drift() {
let old = snapshot("R", &["ns1.example.com"], "signed");
let mut gone = DomainInfo::from_sources("example.com", None, None);
gone.source = DomainInfoSource::Available;
gone.availability_verdict = Some("available".to_string());
let report = DriftReport::between("example.com", &old, &gone);
assert!(report.has_drift());
assert_eq!(report.changes[0].field, "registration");
assert_eq!(report.changes[0].new.as_deref(), Some("available"));
}
#[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 baseline_snapshot_needs_an_earlier_snapshot() {
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,
dns_nameservers: None,
};
assert!(baseline_snapshot(std::iter::empty()).is_none());
let previous = baseline_snapshot([&result].into_iter()).expect("baseline");
let report = DriftReport::from_lookups("example.com", previous, &result);
assert!(!report.has_drift());
}
}