Skip to main content

seer_core/
diff.rs

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/// Side-by-side comparison of two domains across registration, DNS, and SSL.
10#[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/// Registration data comparison (registrar, organization, dates).
20#[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/// DNS resolution comparison (A records, nameservers, reachability).
29#[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/// SSL certificate comparison (issuer, validity, remaining days).
37#[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/// Compares two domains by running lookups and status checks concurrently.
46#[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    /// Compares two domains, returning their registration, DNS, and SSL differences.
58    ///
59    /// All four network calls (lookup + status for each domain) run concurrently.
60    #[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        // Box::pin each branch so the four heavyweight futures live on the heap
66        // instead of inlining into the join! state. Each lookup/status future
67        // itself joins many protocol futures; inlined, the combined `diff`
68        // future is ~105 KB and a single poll overflows a tokio worker stack in
69        // debug builds (TUI `:diff a b` crash). Boxing keeps the poll shallow.
70        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
93/// `YYYY-MM-DD`, the date form every diff field uses.
94fn 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    // Both dates share LookupResult's RDAP-then-WHOIS fallback, so a diff is
100    // never internally inconsistent about where its dates came from.
101    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        // RDAP response with no `registration` event -> creation_date() is None.
217        let rdap = RdapResponse::default();
218        assert!(rdap.creation_date().is_none());
219
220        // WHOIS fallback carries a creation date.
221        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        // created must use the WHOIS fallback, matching the expiration ladder,
236        // rather than being left None.
237        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        // Just verify construction works
282        let _ = differ;
283    }
284
285    /// Regression guard for a debug-build stack overflow (TUI `:diff a b`).
286    ///
287    /// `diff()` joins four heavyweight futures (lookup ×2, status ×2), each of
288    /// which internally joins many protocol futures (RDAP/WHOIS/DNS/SSL/...).
289    /// If those four are inlined into the `join!` state instead of boxed, the
290    /// combined future balloons to tens of KB; a single inline poll then
291    /// overflows a 2 MB tokio worker stack once nested under the TUI's extra
292    /// `handle_action → data::fetch` wrapper frames. Boxing the joined futures
293    /// keeps this future small and the poll shallow.
294    #[test]
295    fn diff_future_stays_small_enough_for_the_stack() {
296        let differ = DomainDiffer::new();
297        // Construct but never await — keeps the test hermetic (no network).
298        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}