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
17const LOOKUP_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
19
20const PROTOCOL_GRACE_PERIOD: Duration = Duration::from_secs(5);
24
25static LOOKUP_CACHE: Lazy<TtlCache<String, LookupResult>> =
27 Lazy::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
28
29pub 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 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 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 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 pub fn is_rdap(&self) -> bool {
94 matches!(self, LookupResult::Rdap { .. })
95 }
96
97 pub fn is_whois(&self) -> bool {
99 matches!(self, LookupResult::Whois { .. })
100 }
101
102 pub fn is_available(&self) -> bool {
104 matches!(self, LookupResult::Available { .. })
105 }
106
107 pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
109 match self {
110 LookupResult::Rdap {
111 data,
112 whois_fallback,
113 } => {
114 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 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
137fn 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 prefer_rdap: bool,
174 include_fallback: bool,
176}
177
178impl Default for SmartLookup {
179 fn default() -> Self {
180 Self::new()
181 }
182}
183
184impl SmartLookup {
185 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(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(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 #[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 #[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 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 LOOKUP_CACHE.insert(normalized, trim_for_cache(result.clone()));
242
243 Ok(result)
244 }
245
246 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 let (rdap_result, whois_result) = tokio::select! {
271 rdap_res = &mut rdap_fut => {
272 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 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 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 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 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 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 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 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 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}