Skip to main content

seer_core/
lookup.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use chrono::{DateTime, Utc};
5use once_cell::sync::Lazy;
6use serde::{Deserialize, Serialize};
7use tracing::{debug, instrument, warn};
8
9use tokio::time::timeout as tokio_timeout;
10
11use crate::availability::{AvailabilityChecker, AvailabilityResult};
12use crate::cache::TtlCache;
13use crate::error::{Result, SeerError};
14use crate::rdap::{RdapClient, RdapResponse};
15use crate::whois::{get_registry_url, get_tld, WhoisClient, WhoisResponse};
16
17/// Cache TTL for lookup results (5 minutes).
18const LOOKUP_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
19
20/// Grace period for the second protocol after the first one finishes.
21/// If WHOIS finishes and RDAP hasn't responded within this window, we
22/// use the WHOIS result rather than waiting the full RDAP timeout.
23const PROTOCOL_GRACE_PERIOD: Duration = Duration::from_secs(5);
24
25/// Global cache for lookup results to avoid redundant network calls.
26static LOOKUP_CACHE: Lazy<TtlCache<String, LookupResult>> =
27    Lazy::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
28
29/// Progress callback for smart lookup operations.
30/// Called with a message describing the current phase of the lookup.
31pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(tag = "source", rename_all = "lowercase")]
35pub enum LookupResult {
36    Rdap {
37        data: Box<RdapResponse>,
38        #[serde(skip_serializing_if = "Option::is_none")]
39        whois_fallback: Option<WhoisResponse>,
40    },
41    Whois {
42        data: WhoisResponse,
43        rdap_error: Option<String>,
44        #[serde(skip_serializing_if = "Option::is_none")]
45        rdap_fallback: Option<Box<RdapResponse>>,
46    },
47    Available {
48        data: Box<AvailabilityResult>,
49        rdap_error: String,
50        whois_error: String,
51    },
52}
53
54impl LookupResult {
55    /// Returns the domain name from the lookup result.
56    pub fn domain_name(&self) -> Option<String> {
57        match self {
58            LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
59            LookupResult::Whois { data, .. } => Some(data.domain.clone()),
60            LookupResult::Available { data, .. } => Some(data.domain.clone()),
61        }
62    }
63
64    /// Returns the registrar name, preferring RDAP data with WHOIS fallback.
65    pub fn registrar(&self) -> Option<String> {
66        match self {
67            LookupResult::Rdap {
68                data,
69                whois_fallback,
70            } => data
71                .get_registrar()
72                .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
73            LookupResult::Whois { data, .. } => data.registrar.clone(),
74            LookupResult::Available { .. } => None,
75        }
76    }
77
78    /// Returns the registrant organization, preferring RDAP data with WHOIS fallback.
79    pub fn organization(&self) -> Option<String> {
80        match self {
81            LookupResult::Rdap {
82                data,
83                whois_fallback,
84            } => data
85                .get_registrant_organization()
86                .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
87            LookupResult::Whois { data, .. } => data.organization.clone(),
88            LookupResult::Available { .. } => None,
89        }
90    }
91
92    /// Returns true if the result came from RDAP.
93    pub fn is_rdap(&self) -> bool {
94        matches!(self, LookupResult::Rdap { .. })
95    }
96
97    /// Returns true if the result came from WHOIS.
98    pub fn is_whois(&self) -> bool {
99        matches!(self, LookupResult::Whois { .. })
100    }
101
102    /// Returns true if the result is an availability check fallback.
103    pub fn is_available(&self) -> bool {
104        matches!(self, LookupResult::Available { .. })
105    }
106
107    /// Returns the expiration date and registrar info from the lookup result.
108    pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
109        match self {
110            LookupResult::Rdap {
111                data,
112                whois_fallback,
113            } => {
114                // Try to get expiration from RDAP events
115                let expiration_date = data
116                    .events
117                    .iter()
118                    .find(|e| e.event_action == "expiration")
119                    .and_then(|e| e.parsed_date())
120                    .or_else(|| {
121                        // Fallback to WHOIS if available
122                        whois_fallback.as_ref().and_then(|w| w.expiration_date)
123                    });
124
125                let registrar = data
126                    .get_registrar()
127                    .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
128
129                (expiration_date, registrar)
130            }
131            LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
132            LookupResult::Available { .. } => (None, None),
133        }
134    }
135}
136
137/// Before caching, trim raw WHOIS response to limit cache memory.
138/// A full WHOIS raw_response can be up to 1 MB; we cap it at 32 KB which is
139/// plenty for the parsed fields while preventing the cache from ballooning.
140fn trim_for_cache(mut result: LookupResult) -> LookupResult {
141    const MAX_RAW: usize = 32 * 1024;
142
143    match result {
144        LookupResult::Whois { ref mut data, .. } => {
145            if data.raw_response.len() > MAX_RAW {
146                data.raw_response.truncate(MAX_RAW);
147                data.raw_response.push_str("\n... [truncated for cache]");
148            }
149        }
150        LookupResult::Rdap {
151            ref mut whois_fallback,
152            ..
153        } => {
154            if let Some(ref mut w) = whois_fallback {
155                if w.raw_response.len() > MAX_RAW {
156                    w.raw_response.truncate(MAX_RAW);
157                    w.raw_response.push_str("\n... [truncated for cache]");
158                }
159            }
160        }
161        LookupResult::Available { .. } => {}
162    }
163
164    result
165}
166
167#[derive(Debug, Clone)]
168pub struct SmartLookup {
169    rdap_client: RdapClient,
170    whois_client: WhoisClient,
171    availability_checker: AvailabilityChecker,
172    /// Deprecated: both protocols are now always attempted concurrently.
173    prefer_rdap: bool,
174    /// Deprecated: WHOIS data is now always attached when available.
175    include_fallback: bool,
176}
177
178impl Default for SmartLookup {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184impl SmartLookup {
185    /// Creates a new SmartLookup that runs RDAP and WHOIS concurrently,
186    /// falling back to an availability check if both fail.
187    pub fn new() -> Self {
188        Self {
189            rdap_client: RdapClient::new(),
190            whois_client: WhoisClient::new(),
191            availability_checker: AvailabilityChecker::new(),
192            prefer_rdap: true,
193            include_fallback: false,
194        }
195    }
196
197    /// Deprecated: both protocols are now always attempted concurrently.
198    /// This method is kept for API compatibility but has no effect.
199    #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
200    pub fn prefer_rdap(mut self, prefer: bool) -> Self {
201        self.prefer_rdap = prefer;
202        self
203    }
204
205    /// Deprecated: WHOIS data is now always attached when available.
206    /// This method is kept for API compatibility but has no effect.
207    #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
208    pub fn include_fallback(mut self, include: bool) -> Self {
209        self.include_fallback = include;
210        self
211    }
212
213    /// Performs a smart lookup for a domain, trying both RDAP and WHOIS concurrently.
214    /// Falls back to an availability check if both fail.
215    /// Results are cached for 5 minutes to avoid redundant network calls.
216    #[instrument(skip(self), fields(domain = %domain))]
217    pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
218        self.lookup_with_progress(domain, None).await
219    }
220
221    /// Performs a lookup with an optional progress callback.
222    /// The callback is called with messages describing the current phase.
223    /// Results are cached for 5 minutes.
224    #[instrument(skip(self, progress), fields(domain = %domain))]
225    pub async fn lookup_with_progress(
226        &self,
227        domain: &str,
228        progress: Option<LookupProgressCallback>,
229    ) -> Result<LookupResult> {
230        let normalized = crate::validation::normalize_domain(domain)?;
231
232        // Check cache first
233        if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
234            debug!(domain = %normalized, "Returning cached lookup result");
235            return Ok(cached);
236        }
237
238        let result = self.lookup_concurrent(&normalized, progress).await?;
239
240        // Cache a trimmed copy to limit memory usage
241        LOOKUP_CACHE.insert(normalized, trim_for_cache(result.clone()));
242
243        Ok(result)
244    }
245
246    /// Clears the lookup result cache.
247    pub fn clear_cache() {
248        LOOKUP_CACHE.clear();
249    }
250
251    #[instrument(skip(self, progress), fields(domain = %domain))]
252    async fn lookup_concurrent(
253        &self,
254        domain: &str,
255        progress: Option<LookupProgressCallback>,
256    ) -> Result<LookupResult> {
257        debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
258
259        if let Some(ref cb) = progress {
260            cb("Querying RDAP and WHOIS concurrently");
261        }
262
263        let rdap_fut = self.rdap_client.lookup_domain(domain);
264        let whois_fut = self.whois_client.lookup(domain);
265
266        tokio::pin!(rdap_fut);
267        tokio::pin!(whois_fut);
268
269        // Race: whichever finishes first gets a grace period for the other.
270        let (rdap_result, whois_result) = tokio::select! {
271            rdap_res = &mut rdap_fut => {
272                // RDAP finished first — give WHOIS a grace period
273                let whois_res = tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await;
274                let whois_result = match whois_res {
275                    Ok(res) => Some(res),
276                    Err(_) => {
277                        debug!("WHOIS did not finish within grace period, proceeding with RDAP only");
278                        None
279                    }
280                };
281                (Some(rdap_res), whois_result)
282            }
283            whois_res = &mut whois_fut => {
284                // WHOIS finished first — give RDAP a grace period
285                let rdap_res = tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await;
286                let rdap_result = match rdap_res {
287                    Ok(res) => Some(res),
288                    Err(_) => {
289                        debug!("RDAP did not finish within grace period, proceeding with WHOIS only");
290                        None
291                    }
292                };
293                (rdap_result, Some(whois_res))
294            }
295        };
296
297        // Phase 1: If RDAP returned useful data, use it as primary
298        if let Some(Ok(rdap_data)) = rdap_result {
299            if self.is_rdap_response_useful(&rdap_data) {
300                debug!("RDAP lookup successful");
301                let whois_fallback = whois_result.and_then(|r| r.ok());
302                return Ok(LookupResult::Rdap {
303                    data: Box::new(rdap_data),
304                    whois_fallback,
305                });
306            }
307
308            // RDAP succeeded but response wasn't useful — try WHOIS
309            if let Some(Ok(whois_data)) = whois_result {
310                debug!("RDAP response incomplete, using WHOIS result");
311                if let Some(ref cb) = progress {
312                    cb("RDAP response incomplete (using WHOIS)");
313                }
314                return Ok(LookupResult::Whois {
315                    data: whois_data,
316                    rdap_error: Some("RDAP response incomplete".to_string()),
317                    rdap_fallback: Some(Box::new(rdap_data)),
318                });
319            }
320
321            // RDAP not useful, WHOIS also failed or timed out — availability fallback
322            let whois_err_str = match whois_result {
323                Some(Err(e)) => e.to_string(),
324                None => "WHOIS timed out waiting for RDAP".to_string(),
325                _ => unreachable!(),
326            };
327            return self
328                .availability_fallback(
329                    domain,
330                    "RDAP response incomplete".to_string(),
331                    whois_err_str,
332                    progress,
333                )
334                .await;
335        }
336
337        // Phase 2: RDAP failed or timed out — use WHOIS if it succeeded
338        let rdap_error_str = match rdap_result {
339            Some(Err(e)) => e.to_string(),
340            None => "RDAP timed out".to_string(),
341            _ => unreachable!(),
342        };
343
344        if let Some(Ok(whois_data)) = whois_result {
345            debug!("RDAP failed, using WHOIS result");
346            if let Some(ref cb) = progress {
347                cb("RDAP not available (using WHOIS)");
348            }
349            return Ok(LookupResult::Whois {
350                data: whois_data,
351                rdap_error: Some(rdap_error_str),
352                rdap_fallback: None,
353            });
354        }
355
356        // Phase 3: Both failed — try availability check as last resort
357        let whois_error_str = match whois_result {
358            Some(Err(e)) => e.to_string(),
359            None => "WHOIS timed out".to_string(),
360            _ => unreachable!(),
361        };
362        self.availability_fallback(domain, rdap_error_str, whois_error_str, progress)
363            .await
364    }
365
366    async fn availability_fallback(
367        &self,
368        domain: &str,
369        rdap_error: String,
370        whois_error: String,
371        progress: Option<LookupProgressCallback>,
372    ) -> Result<LookupResult> {
373        if let Some(ref cb) = progress {
374            cb("RDAP and WHOIS unavailable (checking availability)");
375        }
376        warn!(
377            domain = %domain,
378            rdap_error = %rdap_error,
379            whois_error = %whois_error,
380            "Both RDAP and WHOIS failed, falling back to availability check"
381        );
382
383        match self.availability_checker.check(domain).await {
384            Ok(avail) => Ok(LookupResult::Available {
385                data: Box::new(avail),
386                rdap_error,
387                whois_error,
388            }),
389            Err(avail_err) => {
390                let tld = get_tld(domain).unwrap_or("unknown");
391                let registry_url = get_registry_url(tld).unwrap_or_else(|| {
392                    format!("https://www.iana.org/domains/root/db/{}.html", tld)
393                });
394                Err(SeerError::LookupFailed {
395                    domain: domain.to_string(),
396                    details: format!(
397                        "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
398                        rdap_error, whois_error, avail_err
399                    ),
400                    registry_url,
401                })
402            }
403        }
404    }
405
406    fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
407        // Check if we have at least some meaningful data
408        let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
409        let has_dates = response
410            .events
411            .iter()
412            .any(|e| e.event_action == "registration" || e.event_action == "expiration");
413        let has_entities = !response.entities.is_empty();
414        let has_nameservers = !response.nameservers.is_empty();
415        let has_status = !response.status.is_empty();
416
417        // Consider useful if we have the name plus at least one other piece of info
418        has_name && (has_dates || has_entities || has_nameservers || has_status)
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_lookup_result_domain_name_whois() {
428        let result = LookupResult::Whois {
429            data: WhoisResponse {
430                domain: "example.com".to_string(),
431                registrar: Some("Test Registrar".to_string()),
432                registrant: None,
433                organization: None,
434                registrant_email: None,
435                registrant_phone: None,
436                registrant_address: None,
437                registrant_country: None,
438                admin_name: None,
439                admin_organization: None,
440                admin_email: None,
441                admin_phone: None,
442                tech_name: None,
443                tech_organization: None,
444                tech_email: None,
445                tech_phone: None,
446                creation_date: None,
447                expiration_date: None,
448                updated_date: None,
449                status: vec![],
450                nameservers: vec![],
451                dnssec: None,
452                whois_server: "whois.example.com".to_string(),
453                raw_response: String::new(),
454            },
455            rdap_error: None,
456            rdap_fallback: None,
457        };
458
459        assert_eq!(result.domain_name(), Some("example.com".to_string()));
460        assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
461        assert!(result.is_whois());
462        assert!(!result.is_rdap());
463        assert!(!result.is_available());
464    }
465
466    #[test]
467    fn test_lookup_result_serialization() {
468        let result = LookupResult::Whois {
469            data: WhoisResponse {
470                domain: "test.com".to_string(),
471                registrar: None,
472                registrant: None,
473                organization: None,
474                registrant_email: None,
475                registrant_phone: None,
476                registrant_address: None,
477                registrant_country: None,
478                admin_name: None,
479                admin_organization: None,
480                admin_email: None,
481                admin_phone: None,
482                tech_name: None,
483                tech_organization: None,
484                tech_email: None,
485                tech_phone: None,
486                creation_date: None,
487                expiration_date: None,
488                updated_date: None,
489                status: vec![],
490                nameservers: vec![],
491                dnssec: None,
492                whois_server: String::new(),
493                raw_response: String::new(),
494            },
495            rdap_error: Some("RDAP failed".to_string()),
496            rdap_fallback: None,
497        };
498
499        let json = serde_json::to_string(&result).unwrap();
500        assert!(json.contains("\"source\":\"whois\""));
501        assert!(json.contains("RDAP failed"));
502    }
503
504    #[test]
505    fn test_lookup_result_available_serialization() {
506        let result = LookupResult::Available {
507            data: Box::new(AvailabilityResult {
508                domain: "test123.xyz".to_string(),
509                available: true,
510                confidence: "medium".to_string(),
511                method: "whois_error".to_string(),
512                details: Some("WHOIS server indicates no matching records".to_string()),
513            }),
514            rdap_error: "RDAP failed".to_string(),
515            whois_error: "WHOIS failed".to_string(),
516        };
517
518        let json = serde_json::to_string(&result).unwrap();
519        assert!(json.contains("\"source\":\"available\""));
520        assert!(json.contains("\"available\":true"));
521        assert!(json.contains("test123.xyz"));
522
523        assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
524        assert!(result.is_available());
525        assert!(!result.is_rdap());
526        assert!(!result.is_whois());
527        assert!(result.registrar().is_none());
528        assert_eq!(result.expiration_info(), (None, None));
529    }
530
531    #[test]
532    #[allow(deprecated)]
533    fn test_smart_lookup_builder() {
534        let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
535        assert!(!lookup.prefer_rdap);
536        assert!(lookup.include_fallback);
537    }
538
539    #[test]
540    fn test_lookup_cache_clear() {
541        SmartLookup::clear_cache();
542        assert!(LOOKUP_CACHE.is_empty());
543    }
544}