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 { data, .. } => data
124 .creation_date()
125 .map(|d| d.format("%Y-%m-%d").to_string()),
126 LookupResult::Whois { data, .. } => {
127 data.creation_date.map(|d| d.format("%Y-%m-%d").to_string())
128 }
129 _ => None,
130 };
131 (exp.map(|d| d.format("%Y-%m-%d").to_string()), created)
132 })
133 .unwrap_or((None, None))
134}
135
136fn build_dns_diff(a: Option<&StatusResponse>, b: Option<&StatusResponse>) -> DnsDiff {
137 let (a_records_a, ns_a, resolves_a) = a
138 .and_then(|s| s.dns_resolution.as_ref())
139 .map(|d| (d.a_records.clone(), d.nameservers.clone(), d.resolves))
140 .unwrap_or((vec![], vec![], false));
141
142 let (a_records_b, ns_b, resolves_b) = b
143 .and_then(|s| s.dns_resolution.as_ref())
144 .map(|d| (d.a_records.clone(), d.nameservers.clone(), d.resolves))
145 .unwrap_or((vec![], vec![], false));
146
147 DnsDiff {
148 a_records: (a_records_a, a_records_b),
149 nameservers: (ns_a, ns_b),
150 resolves: (resolves_a, resolves_b),
151 }
152}
153
154fn build_ssl_diff(a: Option<&StatusResponse>, b: Option<&StatusResponse>) -> SslDiff {
155 let (issuer_a, valid_until_a, days_a, is_valid_a) = a
156 .and_then(|s| s.certificate.as_ref())
157 .map(|c| {
158 (
159 Some(c.issuer.clone()),
160 Some(c.valid_until.format("%Y-%m-%d").to_string()),
161 Some(c.days_until_expiry),
162 Some(c.is_valid),
163 )
164 })
165 .unwrap_or((None, None, None, None));
166
167 let (issuer_b, valid_until_b, days_b, is_valid_b) = b
168 .and_then(|s| s.certificate.as_ref())
169 .map(|c| {
170 (
171 Some(c.issuer.clone()),
172 Some(c.valid_until.format("%Y-%m-%d").to_string()),
173 Some(c.days_until_expiry),
174 Some(c.is_valid),
175 )
176 })
177 .unwrap_or((None, None, None, None));
178
179 SslDiff {
180 issuer: (issuer_a, issuer_b),
181 valid_until: (valid_until_a, valid_until_b),
182 days_remaining: (days_a, days_b),
183 is_valid: (is_valid_a, is_valid_b),
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn test_domain_diff_serialization() {
193 let diff = DomainDiff {
194 domain_a: "example.com".to_string(),
195 domain_b: "google.com".to_string(),
196 registration: RegistrationDiff {
197 registrar: (Some("IANA".to_string()), Some("MarkMonitor".to_string())),
198 organization: (None, Some("Google LLC".to_string())),
199 created: (
200 Some("1995-08-14".to_string()),
201 Some("1997-09-15".to_string()),
202 ),
203 expires: (
204 Some("2026-08-13".to_string()),
205 Some("2028-09-14".to_string()),
206 ),
207 },
208 dns: DnsDiff {
209 a_records: (
210 vec!["93.184.216.34".to_string()],
211 vec!["142.250.185.46".to_string()],
212 ),
213 nameservers: (
214 vec!["a.iana-servers.net".to_string()],
215 vec!["ns1.google.com".to_string()],
216 ),
217 resolves: (true, true),
218 },
219 ssl: SslDiff {
220 issuer: (
221 Some("DigiCert Inc".to_string()),
222 Some("Google Trust Services".to_string()),
223 ),
224 valid_until: (
225 Some("2025-03-01".to_string()),
226 Some("2025-02-15".to_string()),
227 ),
228 days_remaining: (Some(89), Some(75)),
229 is_valid: (Some(true), Some(true)),
230 },
231 };
232
233 let json = serde_json::to_string(&diff).unwrap();
234 assert!(json.contains("example.com"));
235 assert!(json.contains("google.com"));
236 assert!(json.contains("IANA"));
237 assert!(json.contains("MarkMonitor"));
238 }
239
240 #[test]
241 fn test_build_registration_diff_both_none() {
242 let diff = build_registration_diff(None, None);
243 assert!(diff.registrar.0.is_none());
244 assert!(diff.registrar.1.is_none());
245 assert!(diff.organization.0.is_none());
246 assert!(diff.organization.1.is_none());
247 assert!(diff.created.0.is_none());
248 assert!(diff.created.1.is_none());
249 assert!(diff.expires.0.is_none());
250 assert!(diff.expires.1.is_none());
251 }
252
253 #[test]
254 fn test_build_dns_diff_both_none() {
255 let diff = build_dns_diff(None, None);
256 assert!(diff.a_records.0.is_empty());
257 assert!(diff.a_records.1.is_empty());
258 assert!(diff.nameservers.0.is_empty());
259 assert!(diff.nameservers.1.is_empty());
260 assert!(!diff.resolves.0);
261 assert!(!diff.resolves.1);
262 }
263
264 #[test]
265 fn test_build_ssl_diff_both_none() {
266 let diff = build_ssl_diff(None, None);
267 assert!(diff.issuer.0.is_none());
268 assert!(diff.issuer.1.is_none());
269 assert!(diff.valid_until.0.is_none());
270 assert!(diff.valid_until.1.is_none());
271 assert!(diff.days_remaining.0.is_none());
272 assert!(diff.days_remaining.1.is_none());
273 assert!(diff.is_valid.0.is_none());
274 assert!(diff.is_valid.1.is_none());
275 }
276
277 #[test]
278 fn test_domain_differ_default() {
279 let differ = DomainDiffer::default();
280 let _ = differ;
282 }
283
284 #[test]
285 fn test_extract_dates_none() {
286 let (exp, created) = extract_dates(None);
287 assert!(exp.is_none());
288 assert!(created.is_none());
289 }
290
291 #[test]
301 fn diff_future_stays_small_enough_for_the_stack() {
302 let differ = DomainDiffer::new();
303 let fut = differ.diff("example.com", "example.org");
305 let size = std::mem::size_of_val(&fut);
306 assert!(
307 size < 8_192,
308 "diff() future is {size} bytes; the four joined lookup/status \
309 futures must be boxed so they don't inline onto the poll stack"
310 );
311 }
312}