1use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use tracing::{debug, instrument};
13
14use crate::error::{Result, SeerError};
15use crate::lookup::{LookupResult, SmartLookup};
16use crate::status::{StatusClient, StatusResponse};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DomainDiff {
21 pub domain_a: String,
22 pub domain_b: String,
23 pub registration: RegistrationDiff,
24 pub dns: DnsDiff,
25 pub ssl: SslDiff,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
29 pub errors: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct RegistrationDiff {
35 pub registrar: (Option<String>, Option<String>),
36 pub organization: (Option<String>, Option<String>),
37 pub created: (Option<String>, Option<String>),
38 pub expires: (Option<String>, Option<String>),
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct DnsDiff {
44 pub a_records: (Vec<String>, Vec<String>),
45 pub nameservers: (Vec<String>, Vec<String>),
46 pub resolves: (Option<bool>, Option<bool>),
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SslDiff {
54 pub issuer: (Option<String>, Option<String>),
55 pub valid_until: (Option<String>, Option<String>),
56 pub days_remaining: (Option<i64>, Option<i64>),
57 pub is_valid: (Option<bool>, Option<bool>),
58}
59
60#[derive(Default)]
62pub struct DomainDiffer {
63 lookup: SmartLookup,
64 status_client: StatusClient,
65}
66
67impl DomainDiffer {
68 pub fn new() -> Self {
69 Self::default()
70 }
71
72 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
75 Self {
76 lookup: SmartLookup::from_config(config),
77 status_client: StatusClient::from_config(config),
78 }
79 }
80
81 #[instrument(skip(self), fields(domain_a = %domain_a, domain_b = %domain_b))]
85 pub async fn diff(&self, domain_a: &str, domain_b: &str) -> Result<DomainDiff> {
86 let domain_a = crate::validation::normalize_domain(domain_a)?;
87 let domain_b = crate::validation::normalize_domain(domain_b)?;
88
89 let (lookup_a, lookup_b, status_a, status_b) = tokio::join!(
95 Box::pin(self.lookup.lookup(&domain_a)),
96 Box::pin(self.lookup.lookup(&domain_b)),
97 Box::pin(self.status_client.check(&domain_a)),
98 Box::pin(self.status_client.check(&domain_b)),
99 );
100
101 let registration = build_registration_diff(lookup_a.as_ref().ok(), lookup_b.as_ref().ok());
102 let dns = build_dns_diff(status_a.as_ref().ok(), status_b.as_ref().ok());
103 let ssl = build_ssl_diff(status_a.as_ref().ok(), status_b.as_ref().ok());
104
105 let errors = failures(&[
106 (&domain_a, "registration lookup", lookup_a.as_ref().err()),
107 (&domain_b, "registration lookup", lookup_b.as_ref().err()),
108 (&domain_a, "status check", status_a.as_ref().err()),
109 (&domain_b, "status check", status_b.as_ref().err()),
110 ]);
111
112 debug!("Domain diff complete");
113
114 Ok(DomainDiff {
115 domain_a,
116 domain_b,
117 registration,
118 dns,
119 ssl,
120 errors,
121 })
122 }
123}
124
125fn failures(legs: &[(&str, &str, Option<&SeerError>)]) -> Vec<String> {
128 legs.iter()
129 .filter_map(|(domain, check, failure)| {
130 let e = (*failure)?;
131 debug!(%domain, error = %e, "{check} failed");
132 Some(format!(
133 "{domain}: {check} failed: {}",
134 e.sanitized_message()
135 ))
136 })
137 .collect()
138}
139
140fn ymd(date: DateTime<Utc>) -> String {
142 date.format("%Y-%m-%d").to_string()
143}
144
145fn build_registration_diff(a: Option<&LookupResult>, b: Option<&LookupResult>) -> RegistrationDiff {
146 let side = |r: Option<&LookupResult>| {
149 (
150 r.and_then(LookupResult::registrar),
151 r.and_then(LookupResult::organization),
152 r.and_then(LookupResult::creation_date).map(ymd),
153 r.and_then(LookupResult::expiration_date).map(ymd),
154 )
155 };
156 let ((registrar_a, org_a, created_a, expires_a), (registrar_b, org_b, created_b, expires_b)) =
157 (side(a), side(b));
158
159 RegistrationDiff {
160 registrar: (registrar_a, registrar_b),
161 organization: (org_a, org_b),
162 created: (created_a, created_b),
163 expires: (expires_a, expires_b),
164 }
165}
166
167fn build_dns_diff(a: Option<&StatusResponse>, b: Option<&StatusResponse>) -> DnsDiff {
168 let side = |s: Option<&StatusResponse>| match s.and_then(|s| s.dns_resolution.as_ref()) {
169 Some(d) => (d.a_records.clone(), d.nameservers.clone(), Some(d.resolves)),
170 None => (Vec::new(), Vec::new(), None),
171 };
172 let ((a_records_a, ns_a, resolves_a), (a_records_b, ns_b, resolves_b)) = (side(a), side(b));
173
174 DnsDiff {
175 a_records: (a_records_a, a_records_b),
176 nameservers: (ns_a, ns_b),
177 resolves: (resolves_a, resolves_b),
178 }
179}
180
181fn build_ssl_diff(a: Option<&StatusResponse>, b: Option<&StatusResponse>) -> SslDiff {
182 let side = |s: Option<&StatusResponse>| {
183 let cert = s.and_then(|s| s.certificate.as_ref());
184 (
185 cert.map(|c| c.issuer.clone()),
186 cert.map(|c| ymd(c.valid_until)),
187 cert.map(|c| c.days_until_expiry),
188 cert.map(|c| c.is_valid),
189 )
190 };
191 let (
192 (issuer_a, valid_until_a, days_a, is_valid_a),
193 (issuer_b, valid_until_b, days_b, is_valid_b),
194 ) = (side(a), side(b));
195
196 SslDiff {
197 issuer: (issuer_a, issuer_b),
198 valid_until: (valid_until_a, valid_until_b),
199 days_remaining: (days_a, days_b),
200 is_valid: (is_valid_a, is_valid_b),
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn test_domain_diff_serialization() {
210 let diff = DomainDiff {
211 domain_a: "example.com".to_string(),
212 domain_b: "google.com".to_string(),
213 registration: RegistrationDiff {
214 registrar: (Some("IANA".to_string()), Some("MarkMonitor".to_string())),
215 organization: (None, Some("Google LLC".to_string())),
216 created: (
217 Some("1995-08-14".to_string()),
218 Some("1997-09-15".to_string()),
219 ),
220 expires: (
221 Some("2026-08-13".to_string()),
222 Some("2028-09-14".to_string()),
223 ),
224 },
225 dns: DnsDiff {
226 a_records: (
227 vec!["93.184.216.34".to_string()],
228 vec!["142.250.185.46".to_string()],
229 ),
230 nameservers: (
231 vec!["a.iana-servers.net".to_string()],
232 vec!["ns1.google.com".to_string()],
233 ),
234 resolves: (Some(true), Some(true)),
235 },
236 ssl: SslDiff {
237 issuer: (
238 Some("DigiCert Inc".to_string()),
239 Some("Google Trust Services".to_string()),
240 ),
241 valid_until: (
242 Some("2025-03-01".to_string()),
243 Some("2025-02-15".to_string()),
244 ),
245 days_remaining: (Some(89), Some(75)),
246 is_valid: (Some(true), Some(true)),
247 },
248 errors: Vec::new(),
249 };
250
251 let json = serde_json::to_string(&diff).unwrap();
252 assert!(json.contains("example.com"));
253 assert!(json.contains("google.com"));
254 assert!(json.contains("IANA"));
255 assert!(json.contains("MarkMonitor"));
256 }
257
258 #[test]
259 fn extract_dates_rdap_falls_back_to_whois_creation_date() {
260 use crate::rdap::RdapResponse;
261 use crate::whois::WhoisResponse;
262
263 let rdap = RdapResponse::default();
265 assert!(rdap.creation_date().is_none());
266
267 let whois = WhoisResponse::parse_internal(
269 "example.com",
270 "whois.verisign-grs.com",
271 "Domain Name: example.com\nCreation Date: 1995-08-14T04:00:00Z\n",
272 );
273 assert!(
274 whois.creation_date.is_some(),
275 "whois should parse creation date"
276 );
277
278 let result = LookupResult::Rdap {
279 data: Box::new(rdap),
280 whois_fallback: Some(whois),
281 };
282 let diff = build_registration_diff(Some(&result), None);
285 assert_eq!(diff.created.0.as_deref(), Some("1995-08-14"));
286 }
287
288 #[test]
289 fn test_build_registration_diff_both_none() {
290 let diff = build_registration_diff(None, None);
291 assert!(diff.registrar.0.is_none());
292 assert!(diff.registrar.1.is_none());
293 assert!(diff.organization.0.is_none());
294 assert!(diff.organization.1.is_none());
295 assert!(diff.created.0.is_none());
296 assert!(diff.created.1.is_none());
297 assert!(diff.expires.0.is_none());
298 assert!(diff.expires.1.is_none());
299 }
300
301 #[test]
302 fn test_build_dns_diff_both_none() {
303 let diff = build_dns_diff(None, None);
304 assert!(diff.a_records.0.is_empty());
305 assert!(diff.a_records.1.is_empty());
306 assert!(diff.nameservers.0.is_empty());
307 assert!(diff.nameservers.1.is_empty());
308 assert_eq!(diff.resolves, (None, None));
310 }
311
312 #[test]
313 fn failed_legs_are_reported_not_defaulted() {
314 let err = SeerError::Timeout("status check timed out".to_string());
315 let errors = failures(&[
316 ("a.test", "registration lookup", None),
317 ("b.test", "status check", Some(&err)),
318 ]);
319 assert_eq!(errors.len(), 1);
320 assert!(
321 errors[0].starts_with("b.test: status check failed: "),
322 "{errors:?}"
323 );
324 let json = |errors: Vec<String>| {
326 serde_json::to_value(DomainDiff {
327 domain_a: "a.test".into(),
328 domain_b: "b.test".into(),
329 registration: build_registration_diff(None, None),
330 dns: build_dns_diff(None, None),
331 ssl: build_ssl_diff(None, None),
332 errors,
333 })
334 .unwrap()
335 };
336 assert!(json(Vec::new()).get("errors").is_none());
337 assert_eq!(json(errors)["errors"].as_array().map(Vec::len), Some(1));
338 }
339
340 #[test]
341 fn test_build_ssl_diff_both_none() {
342 let diff = build_ssl_diff(None, None);
343 assert!(diff.issuer.0.is_none());
344 assert!(diff.issuer.1.is_none());
345 assert!(diff.valid_until.0.is_none());
346 assert!(diff.valid_until.1.is_none());
347 assert!(diff.days_remaining.0.is_none());
348 assert!(diff.days_remaining.1.is_none());
349 assert!(diff.is_valid.0.is_none());
350 assert!(diff.is_valid.1.is_none());
351 }
352
353 #[test]
354 fn test_domain_differ_default() {
355 let differ = DomainDiffer::default();
356 let _ = differ;
358 }
359
360 #[test]
370 fn diff_future_stays_small_enough_for_the_stack() {
371 let differ = DomainDiffer::new();
372 let fut = differ.diff("example.com", "example.org");
374 let size = size_of_val(&fut);
375 assert!(
376 size < 8_192,
377 "diff() future is {size} bytes; the four joined lookup/status \
378 futures must be boxed so they don't inline onto the poll stack"
379 );
380 }
381}