use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::{debug, instrument};
use crate::error::{Result, SeerError};
use crate::lookup::{LookupResult, SmartLookup};
use crate::status::{StatusClient, StatusResponse};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainDiff {
pub domain_a: String,
pub domain_b: String,
pub registration: RegistrationDiff,
pub dns: DnsDiff,
pub ssl: SslDiff,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistrationDiff {
pub registrar: (Option<String>, Option<String>),
pub organization: (Option<String>, Option<String>),
pub created: (Option<String>, Option<String>),
pub expires: (Option<String>, Option<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DnsDiff {
pub a_records: (Vec<String>, Vec<String>),
pub nameservers: (Vec<String>, Vec<String>),
pub resolves: (Option<bool>, Option<bool>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SslDiff {
pub issuer: (Option<String>, Option<String>),
pub valid_until: (Option<String>, Option<String>),
pub days_remaining: (Option<i64>, Option<i64>),
pub is_valid: (Option<bool>, Option<bool>),
}
#[derive(Default)]
pub struct DomainDiffer {
lookup: SmartLookup,
status_client: StatusClient,
}
impl DomainDiffer {
pub fn new() -> Self {
Self::default()
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self {
lookup: SmartLookup::from_config(config),
status_client: StatusClient::from_config(config),
}
}
#[instrument(skip(self), fields(domain_a = %domain_a, domain_b = %domain_b))]
pub async fn diff(&self, domain_a: &str, domain_b: &str) -> Result<DomainDiff> {
let domain_a = crate::validation::normalize_domain(domain_a)?;
let domain_b = crate::validation::normalize_domain(domain_b)?;
let (lookup_a, lookup_b, status_a, status_b) = tokio::join!(
Box::pin(self.lookup.lookup(&domain_a)),
Box::pin(self.lookup.lookup(&domain_b)),
Box::pin(self.status_client.check(&domain_a)),
Box::pin(self.status_client.check(&domain_b)),
);
let registration = build_registration_diff(lookup_a.as_ref().ok(), lookup_b.as_ref().ok());
let dns = build_dns_diff(status_a.as_ref().ok(), status_b.as_ref().ok());
let ssl = build_ssl_diff(status_a.as_ref().ok(), status_b.as_ref().ok());
let errors = failures(&[
(&domain_a, "registration lookup", lookup_a.as_ref().err()),
(&domain_b, "registration lookup", lookup_b.as_ref().err()),
(&domain_a, "status check", status_a.as_ref().err()),
(&domain_b, "status check", status_b.as_ref().err()),
]);
debug!("Domain diff complete");
Ok(DomainDiff {
domain_a,
domain_b,
registration,
dns,
ssl,
errors,
})
}
}
fn failures(legs: &[(&str, &str, Option<&SeerError>)]) -> Vec<String> {
legs.iter()
.filter_map(|(domain, check, failure)| {
let e = (*failure)?;
debug!(%domain, error = %e, "{check} failed");
Some(format!(
"{domain}: {check} failed: {}",
e.sanitized_message()
))
})
.collect()
}
fn ymd(date: DateTime<Utc>) -> String {
date.format("%Y-%m-%d").to_string()
}
fn build_registration_diff(a: Option<&LookupResult>, b: Option<&LookupResult>) -> RegistrationDiff {
let side = |r: Option<&LookupResult>| {
(
r.and_then(LookupResult::registrar),
r.and_then(LookupResult::organization),
r.and_then(LookupResult::creation_date).map(ymd),
r.and_then(LookupResult::expiration_date).map(ymd),
)
};
let ((registrar_a, org_a, created_a, expires_a), (registrar_b, org_b, created_b, expires_b)) =
(side(a), side(b));
RegistrationDiff {
registrar: (registrar_a, registrar_b),
organization: (org_a, org_b),
created: (created_a, created_b),
expires: (expires_a, expires_b),
}
}
fn build_dns_diff(a: Option<&StatusResponse>, b: Option<&StatusResponse>) -> DnsDiff {
let side = |s: Option<&StatusResponse>| match s.and_then(|s| s.dns_resolution.as_ref()) {
Some(d) => (d.a_records.clone(), d.nameservers.clone(), Some(d.resolves)),
None => (Vec::new(), Vec::new(), None),
};
let ((a_records_a, ns_a, resolves_a), (a_records_b, ns_b, resolves_b)) = (side(a), side(b));
DnsDiff {
a_records: (a_records_a, a_records_b),
nameservers: (ns_a, ns_b),
resolves: (resolves_a, resolves_b),
}
}
fn build_ssl_diff(a: Option<&StatusResponse>, b: Option<&StatusResponse>) -> SslDiff {
let side = |s: Option<&StatusResponse>| {
let cert = s.and_then(|s| s.certificate.as_ref());
(
cert.map(|c| c.issuer.clone()),
cert.map(|c| ymd(c.valid_until)),
cert.map(|c| c.days_until_expiry),
cert.map(|c| c.is_valid),
)
};
let (
(issuer_a, valid_until_a, days_a, is_valid_a),
(issuer_b, valid_until_b, days_b, is_valid_b),
) = (side(a), side(b));
SslDiff {
issuer: (issuer_a, issuer_b),
valid_until: (valid_until_a, valid_until_b),
days_remaining: (days_a, days_b),
is_valid: (is_valid_a, is_valid_b),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_domain_diff_serialization() {
let diff = DomainDiff {
domain_a: "example.com".to_string(),
domain_b: "google.com".to_string(),
registration: RegistrationDiff {
registrar: (Some("IANA".to_string()), Some("MarkMonitor".to_string())),
organization: (None, Some("Google LLC".to_string())),
created: (
Some("1995-08-14".to_string()),
Some("1997-09-15".to_string()),
),
expires: (
Some("2026-08-13".to_string()),
Some("2028-09-14".to_string()),
),
},
dns: DnsDiff {
a_records: (
vec!["93.184.216.34".to_string()],
vec!["142.250.185.46".to_string()],
),
nameservers: (
vec!["a.iana-servers.net".to_string()],
vec!["ns1.google.com".to_string()],
),
resolves: (Some(true), Some(true)),
},
ssl: SslDiff {
issuer: (
Some("DigiCert Inc".to_string()),
Some("Google Trust Services".to_string()),
),
valid_until: (
Some("2025-03-01".to_string()),
Some("2025-02-15".to_string()),
),
days_remaining: (Some(89), Some(75)),
is_valid: (Some(true), Some(true)),
},
errors: Vec::new(),
};
let json = serde_json::to_string(&diff).unwrap();
assert!(json.contains("example.com"));
assert!(json.contains("google.com"));
assert!(json.contains("IANA"));
assert!(json.contains("MarkMonitor"));
}
#[test]
fn extract_dates_rdap_falls_back_to_whois_creation_date() {
use crate::rdap::RdapResponse;
use crate::whois::WhoisResponse;
let rdap = RdapResponse::default();
assert!(rdap.creation_date().is_none());
let whois = WhoisResponse::parse_internal(
"example.com",
"whois.verisign-grs.com",
"Domain Name: example.com\nCreation Date: 1995-08-14T04:00:00Z\n",
);
assert!(
whois.creation_date.is_some(),
"whois should parse creation date"
);
let result = LookupResult::Rdap {
data: Box::new(rdap),
whois_fallback: Some(whois),
};
let diff = build_registration_diff(Some(&result), None);
assert_eq!(diff.created.0.as_deref(), Some("1995-08-14"));
}
#[test]
fn test_build_registration_diff_both_none() {
let diff = build_registration_diff(None, None);
assert!(diff.registrar.0.is_none());
assert!(diff.registrar.1.is_none());
assert!(diff.organization.0.is_none());
assert!(diff.organization.1.is_none());
assert!(diff.created.0.is_none());
assert!(diff.created.1.is_none());
assert!(diff.expires.0.is_none());
assert!(diff.expires.1.is_none());
}
#[test]
fn test_build_dns_diff_both_none() {
let diff = build_dns_diff(None, None);
assert!(diff.a_records.0.is_empty());
assert!(diff.a_records.1.is_empty());
assert!(diff.nameservers.0.is_empty());
assert!(diff.nameservers.1.is_empty());
assert_eq!(diff.resolves, (None, None));
}
#[test]
fn failed_legs_are_reported_not_defaulted() {
let err = SeerError::Timeout("status check timed out".to_string());
let errors = failures(&[
("a.test", "registration lookup", None),
("b.test", "status check", Some(&err)),
]);
assert_eq!(errors.len(), 1);
assert!(
errors[0].starts_with("b.test: status check failed: "),
"{errors:?}"
);
let json = |errors: Vec<String>| {
serde_json::to_value(DomainDiff {
domain_a: "a.test".into(),
domain_b: "b.test".into(),
registration: build_registration_diff(None, None),
dns: build_dns_diff(None, None),
ssl: build_ssl_diff(None, None),
errors,
})
.unwrap()
};
assert!(json(Vec::new()).get("errors").is_none());
assert_eq!(json(errors)["errors"].as_array().map(Vec::len), Some(1));
}
#[test]
fn test_build_ssl_diff_both_none() {
let diff = build_ssl_diff(None, None);
assert!(diff.issuer.0.is_none());
assert!(diff.issuer.1.is_none());
assert!(diff.valid_until.0.is_none());
assert!(diff.valid_until.1.is_none());
assert!(diff.days_remaining.0.is_none());
assert!(diff.days_remaining.1.is_none());
assert!(diff.is_valid.0.is_none());
assert!(diff.is_valid.1.is_none());
}
#[test]
fn test_domain_differ_default() {
let differ = DomainDiffer::default();
let _ = differ;
}
#[test]
fn diff_future_stays_small_enough_for_the_stack() {
let differ = DomainDiffer::new();
let fut = differ.diff("example.com", "example.org");
let size = size_of_val(&fut);
assert!(
size < 8_192,
"diff() future is {size} bytes; the four joined lookup/status \
futures must be boxed so they don't inline onto the poll stack"
);
}
}