Skip to main content

seer_core/
diff.rs

1//! Side-by-side comparison of two domains.
2//!
3//! [`DomainDiffer::diff`] runs a smart lookup (registration) and a status
4//! check (DNS resolution + TLS certificate) for each domain concurrently and
5//! lines the results up field by field in a [`DomainDiff`]. A leg that fails
6//! leaves its fields empty (`None` / no records) and records why in
7//! [`DomainDiff::errors`], so a failed check is never shown as a real answer
8//! (a status error used to read as "does not resolve").
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use tracing::{debug, instrument};
13
14use crate::error::{Result, SeerError};
15use crate::lookup::{LookupResult, SmartLookup};
16use crate::status::{StatusClient, StatusResponse};
17
18/// Side-by-side comparison of two domains across registration, DNS, and SSL.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DomainDiff {
21    pub domain_a: String,
22    pub domain_b: String,
23    pub registration: RegistrationDiff,
24    pub dns: DnsDiff,
25    pub ssl: SslDiff,
26    /// Checks that failed (`"<domain>: registration lookup failed: …"`,
27    /// sanitized); their fields above are left empty, not defaulted.
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub errors: Vec<String>,
30}
31
32/// Registration data comparison (registrar, organization, dates).
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct RegistrationDiff {
35    pub registrar: (Option<String>, Option<String>),
36    pub organization: (Option<String>, Option<String>),
37    pub created: (Option<String>, Option<String>),
38    pub expires: (Option<String>, Option<String>),
39}
40
41/// DNS resolution comparison (A records, nameservers, reachability).
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct DnsDiff {
44    pub a_records: (Vec<String>, Vec<String>),
45    pub nameservers: (Vec<String>, Vec<String>),
46    /// Whether the domain resolves; `None` when its status check failed or
47    /// did not check DNS.
48    pub resolves: (Option<bool>, Option<bool>),
49}
50
51/// SSL certificate comparison (issuer, validity, remaining days).
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SslDiff {
54    pub issuer: (Option<String>, Option<String>),
55    pub valid_until: (Option<String>, Option<String>),
56    pub days_remaining: (Option<i64>, Option<i64>),
57    pub is_valid: (Option<bool>, Option<bool>),
58}
59
60/// Compares two domains by running lookups and status checks concurrently.
61#[derive(Default)]
62pub struct DomainDiffer {
63    lookup: SmartLookup,
64    status_client: StatusClient,
65}
66
67impl DomainDiffer {
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Builds a differ honoring `~/.seer/config.toml`: the lookup and status
73    /// clients take their per-protocol timeouts from `config`.
74    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
75        Self {
76            lookup: SmartLookup::from_config(config),
77            status_client: StatusClient::from_config(config),
78        }
79    }
80
81    /// Compares two domains, returning their registration, DNS, and SSL differences.
82    ///
83    /// All four network calls (lookup + status for each domain) run concurrently.
84    #[instrument(skip(self), fields(domain_a = %domain_a, domain_b = %domain_b))]
85    pub async fn diff(&self, domain_a: &str, domain_b: &str) -> Result<DomainDiff> {
86        let domain_a = crate::validation::normalize_domain(domain_a)?;
87        let domain_b = crate::validation::normalize_domain(domain_b)?;
88
89        // Box::pin each branch so the four heavyweight futures live on the heap
90        // instead of inlining into the join! state. Each lookup/status future
91        // itself joins many protocol futures; inlined, the combined `diff`
92        // future is ~105 KB and a single poll overflows a tokio worker stack in
93        // debug builds (TUI `:diff a b` crash). Boxing keeps the poll shallow.
94        let (lookup_a, lookup_b, status_a, status_b) = tokio::join!(
95            Box::pin(self.lookup.lookup(&domain_a)),
96            Box::pin(self.lookup.lookup(&domain_b)),
97            Box::pin(self.status_client.check(&domain_a)),
98            Box::pin(self.status_client.check(&domain_b)),
99        );
100
101        let registration = build_registration_diff(lookup_a.as_ref().ok(), lookup_b.as_ref().ok());
102        let dns = build_dns_diff(status_a.as_ref().ok(), status_b.as_ref().ok());
103        let ssl = build_ssl_diff(status_a.as_ref().ok(), status_b.as_ref().ok());
104
105        let errors = failures(&[
106            (&domain_a, "registration lookup", lookup_a.as_ref().err()),
107            (&domain_b, "registration lookup", lookup_b.as_ref().err()),
108            (&domain_a, "status check", status_a.as_ref().err()),
109            (&domain_b, "status check", status_b.as_ref().err()),
110        ]);
111
112        debug!("Domain diff complete");
113
114        Ok(DomainDiff {
115            domain_a,
116            domain_b,
117            registration,
118            dns,
119            ssl,
120            errors,
121        })
122    }
123}
124
125/// One sanitized line per failed `(domain, check, error)` leg; the full error
126/// goes to the debug log.
127fn failures(legs: &[(&str, &str, Option<&SeerError>)]) -> Vec<String> {
128    legs.iter()
129        .filter_map(|(domain, check, failure)| {
130            let e = (*failure)?;
131            debug!(%domain, error = %e, "{check} failed");
132            Some(format!(
133                "{domain}: {check} failed: {}",
134                e.sanitized_message()
135            ))
136        })
137        .collect()
138}
139
140/// `YYYY-MM-DD`, the date form every diff field uses.
141fn ymd(date: DateTime<Utc>) -> String {
142    date.format("%Y-%m-%d").to_string()
143}
144
145fn build_registration_diff(a: Option<&LookupResult>, b: Option<&LookupResult>) -> RegistrationDiff {
146    // Both dates share LookupResult's RDAP-then-WHOIS fallback, so a diff is
147    // never internally inconsistent about where its dates came from.
148    let side = |r: Option<&LookupResult>| {
149        (
150            r.and_then(LookupResult::registrar),
151            r.and_then(LookupResult::organization),
152            r.and_then(LookupResult::creation_date).map(ymd),
153            r.and_then(LookupResult::expiration_date).map(ymd),
154        )
155    };
156    let ((registrar_a, org_a, created_a, expires_a), (registrar_b, org_b, created_b, expires_b)) =
157        (side(a), side(b));
158
159    RegistrationDiff {
160        registrar: (registrar_a, registrar_b),
161        organization: (org_a, org_b),
162        created: (created_a, created_b),
163        expires: (expires_a, expires_b),
164    }
165}
166
167fn build_dns_diff(a: Option<&StatusResponse>, b: Option<&StatusResponse>) -> DnsDiff {
168    let side = |s: Option<&StatusResponse>| match s.and_then(|s| s.dns_resolution.as_ref()) {
169        Some(d) => (d.a_records.clone(), d.nameservers.clone(), Some(d.resolves)),
170        None => (Vec::new(), Vec::new(), None),
171    };
172    let ((a_records_a, ns_a, resolves_a), (a_records_b, ns_b, resolves_b)) = (side(a), side(b));
173
174    DnsDiff {
175        a_records: (a_records_a, a_records_b),
176        nameservers: (ns_a, ns_b),
177        resolves: (resolves_a, resolves_b),
178    }
179}
180
181fn build_ssl_diff(a: Option<&StatusResponse>, b: Option<&StatusResponse>) -> SslDiff {
182    let side = |s: Option<&StatusResponse>| {
183        let cert = s.and_then(|s| s.certificate.as_ref());
184        (
185            cert.map(|c| c.issuer.clone()),
186            cert.map(|c| ymd(c.valid_until)),
187            cert.map(|c| c.days_until_expiry),
188            cert.map(|c| c.is_valid),
189        )
190    };
191    let (
192        (issuer_a, valid_until_a, days_a, is_valid_a),
193        (issuer_b, valid_until_b, days_b, is_valid_b),
194    ) = (side(a), side(b));
195
196    SslDiff {
197        issuer: (issuer_a, issuer_b),
198        valid_until: (valid_until_a, valid_until_b),
199        days_remaining: (days_a, days_b),
200        is_valid: (is_valid_a, is_valid_b),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_domain_diff_serialization() {
210        let diff = DomainDiff {
211            domain_a: "example.com".to_string(),
212            domain_b: "google.com".to_string(),
213            registration: RegistrationDiff {
214                registrar: (Some("IANA".to_string()), Some("MarkMonitor".to_string())),
215                organization: (None, Some("Google LLC".to_string())),
216                created: (
217                    Some("1995-08-14".to_string()),
218                    Some("1997-09-15".to_string()),
219                ),
220                expires: (
221                    Some("2026-08-13".to_string()),
222                    Some("2028-09-14".to_string()),
223                ),
224            },
225            dns: DnsDiff {
226                a_records: (
227                    vec!["93.184.216.34".to_string()],
228                    vec!["142.250.185.46".to_string()],
229                ),
230                nameservers: (
231                    vec!["a.iana-servers.net".to_string()],
232                    vec!["ns1.google.com".to_string()],
233                ),
234                resolves: (Some(true), Some(true)),
235            },
236            ssl: SslDiff {
237                issuer: (
238                    Some("DigiCert Inc".to_string()),
239                    Some("Google Trust Services".to_string()),
240                ),
241                valid_until: (
242                    Some("2025-03-01".to_string()),
243                    Some("2025-02-15".to_string()),
244                ),
245                days_remaining: (Some(89), Some(75)),
246                is_valid: (Some(true), Some(true)),
247            },
248            errors: Vec::new(),
249        };
250
251        let json = serde_json::to_string(&diff).unwrap();
252        assert!(json.contains("example.com"));
253        assert!(json.contains("google.com"));
254        assert!(json.contains("IANA"));
255        assert!(json.contains("MarkMonitor"));
256    }
257
258    #[test]
259    fn extract_dates_rdap_falls_back_to_whois_creation_date() {
260        use crate::rdap::RdapResponse;
261        use crate::whois::WhoisResponse;
262
263        // RDAP response with no `registration` event -> creation_date() is None.
264        let rdap = RdapResponse::default();
265        assert!(rdap.creation_date().is_none());
266
267        // WHOIS fallback carries a creation date.
268        let whois = WhoisResponse::parse_internal(
269            "example.com",
270            "whois.verisign-grs.com",
271            "Domain Name: example.com\nCreation Date: 1995-08-14T04:00:00Z\n",
272        );
273        assert!(
274            whois.creation_date.is_some(),
275            "whois should parse creation date"
276        );
277
278        let result = LookupResult::Rdap {
279            data: Box::new(rdap),
280            whois_fallback: Some(whois),
281        };
282        // created must use the WHOIS fallback, matching the expiration ladder,
283        // rather than being left None.
284        let diff = build_registration_diff(Some(&result), None);
285        assert_eq!(diff.created.0.as_deref(), Some("1995-08-14"));
286    }
287
288    #[test]
289    fn test_build_registration_diff_both_none() {
290        let diff = build_registration_diff(None, None);
291        assert!(diff.registrar.0.is_none());
292        assert!(diff.registrar.1.is_none());
293        assert!(diff.organization.0.is_none());
294        assert!(diff.organization.1.is_none());
295        assert!(diff.created.0.is_none());
296        assert!(diff.created.1.is_none());
297        assert!(diff.expires.0.is_none());
298        assert!(diff.expires.1.is_none());
299    }
300
301    #[test]
302    fn test_build_dns_diff_both_none() {
303        let diff = build_dns_diff(None, None);
304        assert!(diff.a_records.0.is_empty());
305        assert!(diff.a_records.1.is_empty());
306        assert!(diff.nameservers.0.is_empty());
307        assert!(diff.nameservers.1.is_empty());
308        // A failed status check is unknown, not "does not resolve".
309        assert_eq!(diff.resolves, (None, None));
310    }
311
312    #[test]
313    fn failed_legs_are_reported_not_defaulted() {
314        let err = SeerError::Timeout("status check timed out".to_string());
315        let errors = failures(&[
316            ("a.test", "registration lookup", None),
317            ("b.test", "status check", Some(&err)),
318        ]);
319        assert_eq!(errors.len(), 1);
320        assert!(
321            errors[0].starts_with("b.test: status check failed: "),
322            "{errors:?}"
323        );
324        // Serialized only when something failed.
325        let json = |errors: Vec<String>| {
326            serde_json::to_value(DomainDiff {
327                domain_a: "a.test".into(),
328                domain_b: "b.test".into(),
329                registration: build_registration_diff(None, None),
330                dns: build_dns_diff(None, None),
331                ssl: build_ssl_diff(None, None),
332                errors,
333            })
334            .unwrap()
335        };
336        assert!(json(Vec::new()).get("errors").is_none());
337        assert_eq!(json(errors)["errors"].as_array().map(Vec::len), Some(1));
338    }
339
340    #[test]
341    fn test_build_ssl_diff_both_none() {
342        let diff = build_ssl_diff(None, None);
343        assert!(diff.issuer.0.is_none());
344        assert!(diff.issuer.1.is_none());
345        assert!(diff.valid_until.0.is_none());
346        assert!(diff.valid_until.1.is_none());
347        assert!(diff.days_remaining.0.is_none());
348        assert!(diff.days_remaining.1.is_none());
349        assert!(diff.is_valid.0.is_none());
350        assert!(diff.is_valid.1.is_none());
351    }
352
353    #[test]
354    fn test_domain_differ_default() {
355        let differ = DomainDiffer::default();
356        // Just verify construction works
357        let _ = differ;
358    }
359
360    /// Regression guard for a debug-build stack overflow (TUI `:diff a b`).
361    ///
362    /// `diff()` joins four heavyweight futures (lookup ×2, status ×2), each of
363    /// which internally joins many protocol futures (RDAP/WHOIS/DNS/SSL/...).
364    /// If those four are inlined into the `join!` state instead of boxed, the
365    /// combined future balloons to tens of KB; a single inline poll then
366    /// overflows a 2 MB tokio worker stack once nested under the TUI's extra
367    /// `handle_action → data::fetch` wrapper frames. Boxing the joined futures
368    /// keeps this future small and the poll shallow.
369    #[test]
370    fn diff_future_stays_small_enough_for_the_stack() {
371        let differ = DomainDiffer::new();
372        // Construct but never await — keeps the test hermetic (no network).
373        let fut = differ.diff("example.com", "example.org");
374        let size = size_of_val(&fut);
375        assert!(
376            size < 8_192,
377            "diff() future is {size} bytes; the four joined lookup/status \
378             futures must be boxed so they don't inline onto the poll stack"
379        );
380    }
381}