Skip to main content

seer_core/
diff.rs

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/// Side-by-side comparison of two domains across registration, DNS, and SSL.
9#[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/// Registration data comparison (registrar, organization, dates).
19#[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/// DNS resolution comparison (A records, nameservers, reachability).
28#[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/// SSL certificate comparison (issuer, validity, remaining days).
36#[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
44/// Compares two domains by running lookups and status checks concurrently.
45pub 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    /// Compares two domains, returning their registration, DNS, and SSL differences.
65    ///
66    /// All four network calls (lookup + status for each domain) run concurrently.
67    #[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        // Box::pin each branch so the four heavyweight futures live on the heap
73        // instead of inlining into the join! state. Each lookup/status future
74        // itself joins many protocol futures; inlined, the combined `diff`
75        // future is ~105 KB and a single poll overflows a tokio worker stack in
76        // debug builds (TUI `:diff a b` crash). Boxing keeps the poll shallow.
77        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
117/// Extracts (expiration, creation) date strings from a lookup result.
118fn 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                // Mirror the expiration ladder in `LookupResult::expiration_info`:
124                // fall back to the WHOIS creation date when the RDAP response has
125                // no `registration` event, so both date fields use the same
126                // fallback and a diff isn't left internally inconsistent.
127                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        // RDAP response with no `registration` event -> creation_date() is None.
254        let rdap = RdapResponse::default();
255        assert!(rdap.creation_date().is_none());
256
257        // WHOIS fallback carries a creation date.
258        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        // created must use the WHOIS fallback, matching the expiration ladder,
273        // rather than being left None.
274        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        // Just verify construction works
319        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    /// Regression guard for a debug-build stack overflow (TUI `:diff a b`).
330    ///
331    /// `diff()` joins four heavyweight futures (lookup ×2, status ×2), each of
332    /// which internally joins many protocol futures (RDAP/WHOIS/DNS/SSL/...).
333    /// If those four are inlined into the `join!` state instead of boxed, the
334    /// combined future balloons to tens of KB; a single inline poll then
335    /// overflows a 2 MB tokio worker stack once nested under the TUI's extra
336    /// `handle_action → data::fetch` wrapper frames. Boxing the joined futures
337    /// keeps this future small and the poll shallow.
338    #[test]
339    fn diff_future_stays_small_enough_for_the_stack() {
340        let differ = DomainDiffer::new();
341        // Construct but never await — keeps the test hermetic (no network).
342        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}