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        let (lookup_a, lookup_b, status_a, status_b) = tokio::join!(
73            self.lookup.lookup(&domain_a),
74            self.lookup.lookup(&domain_b),
75            self.status_client.check(&domain_a),
76            self.status_client.check(&domain_b),
77        );
78
79        let registration = build_registration_diff(lookup_a.ok().as_ref(), lookup_b.ok().as_ref());
80        let dns = build_dns_diff(status_a.as_ref().ok(), status_b.as_ref().ok());
81        let ssl = build_ssl_diff(status_a.as_ref().ok(), status_b.as_ref().ok());
82
83        debug!("Domain diff complete");
84
85        Ok(DomainDiff {
86            domain_a,
87            domain_b,
88            registration,
89            dns,
90            ssl,
91        })
92    }
93}
94
95fn build_registration_diff(a: Option<&LookupResult>, b: Option<&LookupResult>) -> RegistrationDiff {
96    let registrar_a = a.and_then(|r| r.registrar());
97    let registrar_b = b.and_then(|r| r.registrar());
98    let org_a = a.and_then(|r| r.organization());
99    let org_b = b.and_then(|r| r.organization());
100
101    let (expires_a, created_a) = extract_dates(a);
102    let (expires_b, created_b) = extract_dates(b);
103
104    RegistrationDiff {
105        registrar: (registrar_a, registrar_b),
106        organization: (org_a, org_b),
107        created: (created_a, created_b),
108        expires: (expires_a, expires_b),
109    }
110}
111
112/// Extracts (expiration, creation) date strings from a lookup result.
113fn extract_dates(result: Option<&LookupResult>) -> (Option<String>, Option<String>) {
114    result
115        .map(|r| {
116            let (exp, _) = r.expiration_info();
117            let created = match r {
118                LookupResult::Rdap { data, .. } => data
119                    .creation_date()
120                    .map(|d| d.format("%Y-%m-%d").to_string()),
121                LookupResult::Whois { data, .. } => {
122                    data.creation_date.map(|d| d.format("%Y-%m-%d").to_string())
123                }
124                _ => None,
125            };
126            (exp.map(|d| d.format("%Y-%m-%d").to_string()), created)
127        })
128        .unwrap_or((None, None))
129}
130
131fn build_dns_diff(a: Option<&StatusResponse>, b: Option<&StatusResponse>) -> DnsDiff {
132    let (a_records_a, ns_a, resolves_a) = a
133        .and_then(|s| s.dns_resolution.as_ref())
134        .map(|d| (d.a_records.clone(), d.nameservers.clone(), d.resolves))
135        .unwrap_or((vec![], vec![], false));
136
137    let (a_records_b, ns_b, resolves_b) = b
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    DnsDiff {
143        a_records: (a_records_a, a_records_b),
144        nameservers: (ns_a, ns_b),
145        resolves: (resolves_a, resolves_b),
146    }
147}
148
149fn build_ssl_diff(a: Option<&StatusResponse>, b: Option<&StatusResponse>) -> SslDiff {
150    let (issuer_a, valid_until_a, days_a, is_valid_a) = a
151        .and_then(|s| s.certificate.as_ref())
152        .map(|c| {
153            (
154                Some(c.issuer.clone()),
155                Some(c.valid_until.format("%Y-%m-%d").to_string()),
156                Some(c.days_until_expiry),
157                Some(c.is_valid),
158            )
159        })
160        .unwrap_or((None, None, None, None));
161
162    let (issuer_b, valid_until_b, days_b, is_valid_b) = b
163        .and_then(|s| s.certificate.as_ref())
164        .map(|c| {
165            (
166                Some(c.issuer.clone()),
167                Some(c.valid_until.format("%Y-%m-%d").to_string()),
168                Some(c.days_until_expiry),
169                Some(c.is_valid),
170            )
171        })
172        .unwrap_or((None, None, None, None));
173
174    SslDiff {
175        issuer: (issuer_a, issuer_b),
176        valid_until: (valid_until_a, valid_until_b),
177        days_remaining: (days_a, days_b),
178        is_valid: (is_valid_a, is_valid_b),
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_domain_diff_serialization() {
188        let diff = DomainDiff {
189            domain_a: "example.com".to_string(),
190            domain_b: "google.com".to_string(),
191            registration: RegistrationDiff {
192                registrar: (Some("IANA".to_string()), Some("MarkMonitor".to_string())),
193                organization: (None, Some("Google LLC".to_string())),
194                created: (
195                    Some("1995-08-14".to_string()),
196                    Some("1997-09-15".to_string()),
197                ),
198                expires: (
199                    Some("2026-08-13".to_string()),
200                    Some("2028-09-14".to_string()),
201                ),
202            },
203            dns: DnsDiff {
204                a_records: (
205                    vec!["93.184.216.34".to_string()],
206                    vec!["142.250.185.46".to_string()],
207                ),
208                nameservers: (
209                    vec!["a.iana-servers.net".to_string()],
210                    vec!["ns1.google.com".to_string()],
211                ),
212                resolves: (true, true),
213            },
214            ssl: SslDiff {
215                issuer: (
216                    Some("DigiCert Inc".to_string()),
217                    Some("Google Trust Services".to_string()),
218                ),
219                valid_until: (
220                    Some("2025-03-01".to_string()),
221                    Some("2025-02-15".to_string()),
222                ),
223                days_remaining: (Some(89), Some(75)),
224                is_valid: (Some(true), Some(true)),
225            },
226        };
227
228        let json = serde_json::to_string(&diff).unwrap();
229        assert!(json.contains("example.com"));
230        assert!(json.contains("google.com"));
231        assert!(json.contains("IANA"));
232        assert!(json.contains("MarkMonitor"));
233    }
234
235    #[test]
236    fn test_build_registration_diff_both_none() {
237        let diff = build_registration_diff(None, None);
238        assert!(diff.registrar.0.is_none());
239        assert!(diff.registrar.1.is_none());
240        assert!(diff.organization.0.is_none());
241        assert!(diff.organization.1.is_none());
242        assert!(diff.created.0.is_none());
243        assert!(diff.created.1.is_none());
244        assert!(diff.expires.0.is_none());
245        assert!(diff.expires.1.is_none());
246    }
247
248    #[test]
249    fn test_build_dns_diff_both_none() {
250        let diff = build_dns_diff(None, None);
251        assert!(diff.a_records.0.is_empty());
252        assert!(diff.a_records.1.is_empty());
253        assert!(diff.nameservers.0.is_empty());
254        assert!(diff.nameservers.1.is_empty());
255        assert!(!diff.resolves.0);
256        assert!(!diff.resolves.1);
257    }
258
259    #[test]
260    fn test_build_ssl_diff_both_none() {
261        let diff = build_ssl_diff(None, None);
262        assert!(diff.issuer.0.is_none());
263        assert!(diff.issuer.1.is_none());
264        assert!(diff.valid_until.0.is_none());
265        assert!(diff.valid_until.1.is_none());
266        assert!(diff.days_remaining.0.is_none());
267        assert!(diff.days_remaining.1.is_none());
268        assert!(diff.is_valid.0.is_none());
269        assert!(diff.is_valid.1.is_none());
270    }
271
272    #[test]
273    fn test_domain_differ_default() {
274        let differ = DomainDiffer::default();
275        // Just verify construction works
276        let _ = differ;
277    }
278
279    #[test]
280    fn test_extract_dates_none() {
281        let (exp, created) = extract_dates(None);
282        assert!(exp.is_none());
283        assert!(created.is_none());
284    }
285}