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