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