1use crate::error::DomainCheckError;
7use std::collections::{HashMap, HashSet};
8use std::sync::Mutex;
9use std::time::{Duration, Instant};
10
11struct BootstrapCache {
16 rdap_endpoints: HashMap<String, String>,
18 whois_servers: HashMap<String, String>,
20 no_rdap: HashSet<String>,
22 rdap_loaded: bool,
24 last_fetch: Option<Instant>,
26}
27
28const BOOTSTRAP_TTL: Duration = Duration::from_secs(24 * 3600);
30
31impl BootstrapCache {
32 fn new() -> Self {
33 Self {
34 rdap_endpoints: HashMap::new(),
35 whois_servers: HashMap::new(),
36 no_rdap: HashSet::new(),
37 rdap_loaded: false,
38 last_fetch: None,
39 }
40 }
41
42 fn is_stale(&self) -> bool {
43 match self.last_fetch {
44 Some(t) => t.elapsed() > BOOTSTRAP_TTL,
45 None => true,
46 }
47 }
48}
49
50lazy_static::lazy_static! {
52 static ref BOOTSTRAP_CACHE: Mutex<BootstrapCache> = Mutex::new(BootstrapCache::new());
53}
54
55pub fn get_rdap_registry_map() -> HashMap<&'static str, &'static str> {
64 HashMap::from([
65 ("com", "https://rdap.verisign.com/com/v1/domain/"),
67 ("net", "https://rdap.verisign.com/net/v1/domain/"),
68 (
69 "org",
70 "https://rdap.publicinterestregistry.org/rdap/domain/",
71 ),
72 ("info", "https://rdap.identitydigital.services/rdap/domain/"),
73 ("biz", "https://rdap.nic.biz/domain/"),
74 ("app", "https://pubapi.registry.google/rdap/domain/"),
76 ("dev", "https://pubapi.registry.google/rdap/domain/"),
77 ("page", "https://pubapi.registry.google/rdap/domain/"),
78 ("xyz", "https://rdap.centralnic.com/xyz/domain/"),
80 ("tech", "https://rdap.centralnic.com/tech/domain/"),
81 ("online", "https://rdap.centralnic.com/online/domain/"),
82 ("site", "https://rdap.centralnic.com/site/domain/"),
83 ("website", "https://rdap.centralnic.com/website/domain/"),
84 ("blog", "https://rdap.blog.fury.ca/rdap/domain/"),
86 ("shop", "https://rdap.gmoregistry.net/rdap/domain/"),
87 ("ai", "https://rdap.identitydigital.services/rdap/domain/"), ("io", "https://rdap.identitydigital.services/rdap/domain/"), ("me", "https://rdap.identitydigital.services/rdap/domain/"), ("zone", "https://rdap.identitydigital.services/rdap/domain/"),
92 (
93 "digital",
94 "https://rdap.identitydigital.services/rdap/domain/",
95 ),
96 ("us", "https://rdap.nic.us/domain/"), ("uk", "https://rdap.nominet.uk/domain/"), ("de", "https://rdap.denic.de/domain/"), ("ca", "https://rdap.ca.fury.ca/rdap/domain/"), ("au", "https://rdap.cctld.au/rdap/domain/"), ("fr", "https://rdap.nic.fr/domain/"), ("nl", "https://rdap.sidn.nl/domain/"), ("br", "https://rdap.registro.br/domain/"), ("in", "https://rdap.nixiregistry.in/rdap/domain/"), ("tv", "https://rdap.nic.tv/domain/"), ("cc", "https://tld-rdap.verisign.com/cc/v1/domain/"), ("cloud", "https://rdap.registry.cloud/rdap/domain/"),
111 ])
115}
116
117pub fn get_all_known_tlds() -> Vec<String> {
126 let registry = get_rdap_registry_map();
127 let mut tld_set: HashSet<String> = registry.keys().map(|k| k.to_string()).collect();
128
129 if let Ok(cache) = BOOTSTRAP_CACHE.lock() {
131 for tld in cache.rdap_endpoints.keys() {
132 tld_set.insert(tld.clone());
133 }
134 }
135
136 let mut tlds: Vec<String> = tld_set.into_iter().collect();
137 tlds.sort(); tlds
139}
140
141pub fn get_preset_tlds(preset: &str) -> Option<Vec<String>> {
163 let tlds: Option<Vec<&str>> = match preset.to_lowercase().as_str() {
164 "startup" => Some(vec!["com", "org", "io", "ai", "tech", "app", "dev", "xyz"]),
165 "enterprise" => Some(vec!["com", "org", "net", "info", "biz", "us"]),
166 "country" => Some(vec!["us", "uk", "de", "fr", "ca", "au", "br", "in", "nl"]),
167 "popular" => Some(vec![
168 "com", "net", "org", "io", "ai", "app", "dev", "tech", "me", "co", "xyz",
169 ]),
170 "classic" => Some(vec!["com", "net", "org", "info", "biz"]),
171 "tech" => Some(vec![
172 "io",
173 "ai",
174 "app",
175 "dev",
176 "tech",
177 "cloud",
178 "software",
179 "digital",
180 "codes",
181 "systems",
182 "network",
183 "solutions",
184 ]),
185 "creative" => Some(vec![
186 "design",
187 "art",
188 "studio",
189 "media",
190 "photography",
191 "film",
192 "music",
193 "gallery",
194 "graphics",
195 "ink",
196 ]),
197 "ecommerce" | "shopping" => Some(vec![
198 "shop", "store", "market", "sale", "deals", "shopping", "buy", "bargains",
199 ]),
200 "finance" => Some(vec![
201 "finance",
202 "capital",
203 "fund",
204 "money",
205 "investments",
206 "insurance",
207 "tax",
208 "exchange",
209 "trading",
210 ]),
211 "web" => Some(vec![
212 "web", "site", "website", "online", "blog", "page", "wiki", "host", "email",
213 ]),
214 "trendy" => Some(vec![
215 "xyz", "online", "site", "top", "icu", "fun", "space", "click", "website", "life",
216 "world", "live", "today",
217 ]),
218 _ => None,
219 };
220 tlds.map(|v| v.into_iter().map(|s| s.to_string()).collect())
221}
222
223pub fn get_preset_tlds_with_custom(
249 preset: &str,
250 custom_presets: Option<&std::collections::HashMap<String, Vec<String>>>,
251) -> Option<Vec<String>> {
252 let preset_lower = preset.to_lowercase();
253
254 if let Some(custom_map) = custom_presets {
256 if let Some(custom_tlds) = custom_map
258 .get(preset)
259 .or_else(|| custom_map.get(&preset_lower))
260 {
261 return Some(custom_tlds.clone());
262 }
263 }
264
265 get_preset_tlds(&preset_lower)
267}
268
269pub fn get_available_presets() -> Vec<&'static str> {
277 vec![
278 "classic",
279 "country",
280 "creative",
281 "ecommerce",
282 "enterprise",
283 "finance",
284 "popular",
285 "startup",
286 "tech",
287 "trendy",
288 "web",
289 ]
290}
291
292#[allow(dead_code)]
306pub fn validate_preset_tlds(preset_tlds: &[String]) -> bool {
307 let registry = get_rdap_registry_map();
308 preset_tlds
309 .iter()
310 .all(|tld| registry.contains_key(tld.as_str()))
311}
312
313pub async fn get_rdap_endpoint(tld: &str, use_bootstrap: bool) -> Result<String, DomainCheckError> {
331 let tld_lower = tld.to_lowercase();
332
333 let registry = get_rdap_registry_map();
335 if let Some(endpoint) = registry.get(tld_lower.as_str()) {
336 return Ok(endpoint.to_string());
337 }
338
339 {
341 let cache = BOOTSTRAP_CACHE
342 .lock()
343 .map_err(|_| DomainCheckError::internal("Failed to acquire bootstrap cache lock"))?;
344
345 if !cache.is_stale() {
347 if let Some(endpoint) = cache.rdap_endpoints.get(&tld_lower) {
348 return Ok(endpoint.clone());
349 }
350 }
351
352 if cache.no_rdap.contains(&tld_lower) && !cache.is_stale() {
354 return Err(DomainCheckError::bootstrap(
355 &tld_lower,
356 "TLD has no known RDAP endpoint",
357 ));
358 }
359 }
360
361 if use_bootstrap {
363 let needs_fetch = {
365 let cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
366 DomainCheckError::internal("Failed to acquire bootstrap cache lock")
367 })?;
368 !cache.rdap_loaded || cache.is_stale()
369 };
370
371 if needs_fetch {
372 fetch_full_bootstrap().await?;
373 }
374
375 let cache = BOOTSTRAP_CACHE
377 .lock()
378 .map_err(|_| DomainCheckError::internal("Failed to acquire bootstrap cache lock"))?;
379
380 if let Some(endpoint) = cache.rdap_endpoints.get(&tld_lower) {
381 return Ok(endpoint.clone());
382 }
383
384 drop(cache);
386 {
387 let mut cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
388 DomainCheckError::internal("Failed to acquire bootstrap cache lock")
389 })?;
390 cache.no_rdap.insert(tld_lower.clone());
391 }
392
393 Err(DomainCheckError::bootstrap(
394 &tld_lower,
395 "TLD not found in IANA bootstrap registry",
396 ))
397 } else {
398 Err(DomainCheckError::bootstrap(
399 &tld_lower,
400 "No known RDAP endpoint and bootstrap disabled",
401 ))
402 }
403}
404
405async fn fetch_full_bootstrap() -> Result<(), DomainCheckError> {
411 const BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/dns.json";
412
413 let client = reqwest::Client::builder()
414 .timeout(Duration::from_secs(10))
415 .build()
416 .map_err(|e| {
417 DomainCheckError::network_with_source("Failed to create HTTP client", e.to_string())
418 })?;
419
420 let response = client.get(BOOTSTRAP_URL).send().await.map_err(|e| {
421 DomainCheckError::bootstrap("*", format!("Failed to fetch bootstrap registry: {}", e))
422 })?;
423
424 if !response.status().is_success() {
425 return Err(DomainCheckError::bootstrap(
426 "*",
427 format!("Bootstrap registry returned HTTP {}", response.status()),
428 ));
429 }
430
431 let json: serde_json::Value = response.json().await.map_err(|e| {
432 DomainCheckError::bootstrap("*", format!("Failed to parse bootstrap JSON: {}", e))
433 })?;
434
435 let services = json
437 .get("services")
438 .and_then(|s| s.as_array())
439 .ok_or_else(|| {
440 DomainCheckError::bootstrap(
441 "*",
442 "Invalid bootstrap JSON: missing or invalid 'services' array",
443 )
444 })?;
445
446 let mut endpoints: HashMap<String, String> = HashMap::new();
447
448 for service in services {
449 if let Some(service_array) = service.as_array() {
450 if service_array.len() >= 2 {
451 let url = service_array[1]
453 .as_array()
454 .and_then(|urls| urls.first())
455 .and_then(|u| u.as_str());
456
457 if let Some(url) = url {
458 let endpoint = format!("{}/domain/", url.trim_end_matches('/'));
459
460 if let Some(tlds) = service_array[0].as_array() {
462 for t in tlds {
463 if let Some(tld_str) = t.as_str() {
464 endpoints.insert(tld_str.to_lowercase(), endpoint.clone());
465 }
466 }
467 }
468 }
469 }
470 }
471 }
472
473 let mut cache = BOOTSTRAP_CACHE
475 .lock()
476 .map_err(|_| DomainCheckError::internal("Failed to acquire bootstrap cache lock"))?;
477
478 cache.rdap_endpoints = endpoints;
479 cache.rdap_loaded = true;
480 cache.last_fetch = Some(Instant::now());
481 cache.no_rdap.clear(); Ok(())
484}
485
486pub async fn initialize_bootstrap() -> Result<(), DomainCheckError> {
494 let needs_fetch = {
495 let cache = BOOTSTRAP_CACHE
496 .lock()
497 .map_err(|_| DomainCheckError::internal("Failed to acquire bootstrap cache lock"))?;
498 !cache.rdap_loaded || cache.is_stale()
499 };
500
501 if needs_fetch {
502 fetch_full_bootstrap().await?;
503 }
504
505 Ok(())
506}
507
508pub fn cache_whois_server(tld: &str, server: &str) -> Result<(), DomainCheckError> {
510 let mut cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
511 DomainCheckError::internal("Failed to acquire bootstrap cache lock for writing")
512 })?;
513
514 cache
515 .whois_servers
516 .insert(tld.to_lowercase(), server.to_string());
517 Ok(())
518}
519
520pub fn get_cached_whois_server(tld: &str) -> Option<String> {
534 let cache = BOOTSTRAP_CACHE.lock().ok()?;
535 let server = cache.whois_servers.get(&tld.to_lowercase())?;
536 if server.is_empty() {
537 None } else {
539 Some(server.clone())
540 }
541}
542
543pub fn is_whois_negatively_cached(tld: &str) -> bool {
545 if let Ok(cache) = BOOTSTRAP_CACHE.lock() {
546 matches!(cache.whois_servers.get(&tld.to_lowercase()), Some(s) if s.is_empty())
547 } else {
548 false
549 }
550}
551
552pub async fn get_whois_server(tld: &str) -> Option<String> {
567 let tld_lower = tld.to_lowercase();
568
569 if let Some(server) = get_cached_whois_server(&tld_lower) {
571 return Some(server);
572 }
573
574 if is_whois_negatively_cached(&tld_lower) {
576 return None;
577 }
578
579 match crate::protocols::whois::discover_whois_server(&tld_lower).await {
581 Some(server) => {
582 let _ = cache_whois_server(&tld_lower, &server);
583 Some(server)
584 }
585 None => {
586 let _ = cache_whois_server(&tld_lower, "");
588 None
589 }
590 }
591}
592
593pub fn extract_tld(domain: &str) -> Result<String, DomainCheckError> {
606 let parts: Vec<&str> = domain.split('.').collect();
607
608 if parts.len() < 2 {
609 return Err(DomainCheckError::invalid_domain(
610 domain,
611 "Domain must contain at least one dot",
612 ));
613 }
614
615 Ok(parts.last().unwrap().to_lowercase())
619}
620
621#[allow(dead_code)]
623pub fn clear_bootstrap_cache() -> Result<(), DomainCheckError> {
624 let mut cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
625 DomainCheckError::internal("Failed to acquire bootstrap cache lock for clearing")
626 })?;
627
628 cache.rdap_endpoints.clear();
629 cache.whois_servers.clear();
630 cache.no_rdap.clear();
631 cache.rdap_loaded = false;
632 cache.last_fetch = None;
633 Ok(())
634}
635
636#[allow(dead_code)]
638pub fn get_bootstrap_cache_stats() -> Result<(usize, bool), DomainCheckError> {
639 let cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
640 DomainCheckError::internal("Failed to acquire bootstrap cache lock for stats")
641 })?;
642
643 Ok((cache.rdap_endpoints.len(), cache.is_stale()))
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 #[test]
651 fn test_extract_tld() {
652 assert_eq!(extract_tld("example.com").unwrap(), "com");
653 assert_eq!(extract_tld("test.org").unwrap(), "org");
654 assert_eq!(extract_tld("sub.example.com").unwrap(), "com");
655 assert!(extract_tld("invalid").is_err());
656 assert!(extract_tld("").is_err());
657 }
658
659 #[test]
660 fn test_registry_map_contains_common_tlds() {
661 let registry = get_rdap_registry_map();
662 assert!(registry.contains_key("com"));
663 assert!(registry.contains_key("org"));
664 assert!(registry.contains_key("net"));
665 assert!(registry.contains_key("io"));
666 }
667
668 #[tokio::test]
669 async fn test_get_rdap_endpoint_builtin() {
670 let endpoint = get_rdap_endpoint("com", false).await.unwrap();
671 assert!(endpoint.contains("verisign.com"));
672 }
673
674 #[tokio::test]
675 async fn test_get_rdap_endpoint_unknown_no_bootstrap() {
676 let result = get_rdap_endpoint("unknowntld123", false).await;
677 assert!(result.is_err());
678 }
679
680 #[test]
681 fn test_all_endpoints_are_valid_https_urls() {
682 let registry = get_rdap_registry_map();
683 for (tld, endpoint) in ®istry {
684 assert!(
685 endpoint.starts_with("https://"),
686 "Endpoint for '{}' must use HTTPS: {}",
687 tld,
688 endpoint
689 );
690 assert!(
691 endpoint.ends_with("/domain/"),
692 "Endpoint for '{}' must end with /domain/: {}",
693 tld,
694 endpoint
695 );
696 }
697 }
698
699 #[test]
700 fn test_bootstrap_cache_new() {
701 let cache = BootstrapCache::new();
702 assert!(!cache.rdap_loaded);
703 assert!(cache.last_fetch.is_none());
704 assert!(cache.rdap_endpoints.is_empty());
705 assert!(cache.whois_servers.is_empty());
706 assert!(cache.no_rdap.is_empty());
707 assert!(cache.is_stale());
708 }
709
710 #[test]
711 fn test_whois_server_caching() {
712 clear_bootstrap_cache().unwrap();
713
714 cache_whois_server("com", "whois.verisign-grs.com").unwrap();
716 assert_eq!(
717 get_cached_whois_server("com"),
718 Some("whois.verisign-grs.com".to_string())
719 );
720
721 cache_whois_server("fake", "").unwrap();
723 assert_eq!(get_cached_whois_server("fake"), None);
724 assert!(is_whois_negatively_cached("fake"));
725
726 clear_bootstrap_cache().unwrap();
727 }
728}
729
730#[cfg(test)]
731mod preset_tests {
732 use super::*;
733
734 #[test]
735 fn test_get_all_known_tlds() {
736 let tlds = get_all_known_tlds();
737
738 assert!(tlds.len() >= 30);
740 assert!(tlds.contains(&"com".to_string()));
741 assert!(tlds.contains(&"org".to_string()));
742 assert!(tlds.contains(&"io".to_string()));
743 assert!(tlds.contains(&"ai".to_string()));
744
745 let mut sorted_tlds = tlds.clone();
747 sorted_tlds.sort();
748 assert_eq!(tlds, sorted_tlds);
749 }
750
751 #[test]
752 fn test_startup_preset() {
753 let tlds = get_preset_tlds("startup").unwrap();
754
755 assert_eq!(tlds.len(), 8);
756 assert!(tlds.contains(&"com".to_string()));
757 assert!(tlds.contains(&"io".to_string()));
758 assert!(tlds.contains(&"ai".to_string()));
759 assert!(tlds.contains(&"tech".to_string()));
760
761 assert_eq!(get_preset_tlds("STARTUP"), get_preset_tlds("startup"));
763 }
764
765 #[test]
766 fn test_enterprise_preset() {
767 let tlds = get_preset_tlds("enterprise").unwrap();
768
769 assert_eq!(tlds.len(), 6);
770 assert!(tlds.contains(&"com".to_string()));
771 assert!(tlds.contains(&"org".to_string()));
772 assert!(tlds.contains(&"biz".to_string()));
773 }
774
775 #[test]
776 fn test_country_preset() {
777 let tlds = get_preset_tlds("country").unwrap();
778
779 assert_eq!(tlds.len(), 9);
780 assert!(tlds.contains(&"us".to_string()));
781 assert!(tlds.contains(&"uk".to_string()));
782 assert!(tlds.contains(&"de".to_string()));
783 assert!(tlds.contains(&"nl".to_string()));
784 }
785
786 #[test]
787 fn test_invalid_preset() {
788 assert!(get_preset_tlds("invalid").is_none());
789 assert!(get_preset_tlds("").is_none());
790 }
791
792 #[test]
793 fn test_available_presets() {
794 let presets = get_available_presets();
795 assert_eq!(presets.len(), 11);
796 assert!(presets.contains(&"startup"));
797 assert!(presets.contains(&"enterprise"));
798 assert!(presets.contains(&"country"));
799 assert!(presets.contains(&"popular"));
800 assert!(presets.contains(&"classic"));
801 assert!(presets.contains(&"tech"));
802 assert!(presets.contains(&"creative"));
803 assert!(presets.contains(&"ecommerce"));
804 assert!(presets.contains(&"finance"));
805 assert!(presets.contains(&"web"));
806 assert!(presets.contains(&"trendy"));
807 }
808
809 #[test]
810 fn test_validate_preset_tlds() {
811 let core_presets = ["startup", "enterprise", "country", "classic"];
814 for preset_name in &core_presets {
815 let tlds = get_preset_tlds(preset_name).unwrap();
816 assert!(
817 validate_preset_tlds(&tlds),
818 "Core preset '{}' contains TLDs without hardcoded RDAP endpoints",
819 preset_name
820 );
821 }
822 }
823
824 #[test]
825 fn test_all_presets_non_empty() {
826 for preset_name in get_available_presets() {
827 let tlds = get_preset_tlds(preset_name).unwrap();
828 assert!(
829 !tlds.is_empty(),
830 "Preset '{}' should not be empty",
831 preset_name
832 );
833 }
834 }
835
836 #[test]
837 fn test_ecommerce_alias() {
838 assert_eq!(get_preset_tlds("ecommerce"), get_preset_tlds("shopping"));
839 }
840
841 #[test]
842 fn test_preset_tlds_subset_of_known() {
843 let core_presets = ["startup", "enterprise", "country", "classic"];
846 let all_tlds = get_all_known_tlds();
847
848 for preset_name in &core_presets {
849 let preset_tlds = get_preset_tlds(preset_name).unwrap();
850 for tld in preset_tlds {
851 assert!(
852 all_tlds.contains(&tld),
853 "Preset '{}' contains unknown TLD: {}",
854 preset_name,
855 tld
856 );
857 }
858 }
859 }
860}