domain_check_lib/protocols/
registry.rs1use crate::error::DomainCheckError;
7use std::collections::HashMap;
8use std::sync::Mutex;
9use std::time::{Duration, Instant};
10
11struct BootstrapCache {
13 endpoints: HashMap<String, String>,
14 last_update: Instant,
15}
16
17impl BootstrapCache {
18 fn new() -> Self {
19 Self {
20 endpoints: HashMap::new(),
21 last_update: Instant::now(),
22 }
23 }
24
25 fn get(&self, tld: &str) -> Option<String> {
26 self.endpoints.get(tld).cloned()
27 }
28
29 fn insert(&mut self, tld: String, endpoint: String) {
30 self.endpoints.insert(tld, endpoint);
31 self.last_update = Instant::now();
32 }
33
34 fn is_stale(&self) -> bool {
35 self.last_update.elapsed() > Duration::from_secs(3600)
37 }
38}
39
40lazy_static::lazy_static! {
42 static ref BOOTSTRAP_CACHE: Mutex<BootstrapCache> = Mutex::new(BootstrapCache::new());
43}
44
45pub fn get_rdap_registry_map() -> HashMap<&'static str, &'static str> {
54 HashMap::from([
55 ("com", "https://rdap.verisign.com/com/v1/domain/"),
57 ("net", "https://rdap.verisign.com/net/v1/domain/"),
58 (
59 "org",
60 "https://rdap.publicinterestregistry.org/rdap/domain/",
61 ),
62 ("info", "https://rdap.identitydigital.services/rdap/domain/"),
63 ("biz", "https://rdap.nic.biz/domain/"),
64 ("app", "https://rdap.nic.google/domain/"),
66 ("dev", "https://rdap.nic.google/domain/"),
67 ("page", "https://rdap.nic.google/domain/"),
68 ("blog", "https://rdap.nic.blog/domain/"),
70 ("shop", "https://rdap.nic.shop/domain/"),
71 ("xyz", "https://rdap.nic.xyz/domain/"),
72 ("tech", "https://rdap.nic.tech/domain/"),
73 ("online", "https://rdap.nic.online/domain/"),
74 ("site", "https://rdap.nic.site/domain/"),
75 ("website", "https://rdap.nic.website/domain/"),
76 ("io", "https://rdap.identitydigital.services/rdap/domain/"), ("ai", "https://rdap.nic.ai/domain/"), ("co", "https://rdap.nic.co/domain/"), ("me", "https://rdap.nic.me/domain/"), ("us", "https://rdap.nic.us/domain/"), ("uk", "https://rdap.nominet.uk/domain/"), ("eu", "https://rdap.eu.org/domain/"), ("de", "https://rdap.denic.de/domain/"), ("ca", "https://rdap.cira.ca/domain/"), ("au", "https://rdap.auda.org.au/domain/"), ("fr", "https://rdap.nic.fr/domain/"), ("es", "https://rdap.nic.es/domain/"), ("it", "https://rdap.nic.it/domain/"), ("nl", "https://rdap.domain-registry.nl/domain/"), ("jp", "https://rdap.jprs.jp/domain/"), ("br", "https://rdap.registro.br/domain/"), ("in", "https://rdap.registry.in/domain/"), ("cn", "https://rdap.cnnic.cn/domain/"), ("tv", "https://rdap.verisign.com/tv/v1/domain/"), ("cc", "https://rdap.verisign.com/cc/v1/domain/"), ("zone", "https://rdap.nic.zone/domain/"),
100 ("cloud", "https://rdap.nic.cloud/domain/"),
101 ("digital", "https://rdap.nic.digital/domain/"),
102 ])
103}
104
105pub fn get_all_known_tlds() -> Vec<String> {
116 let registry = get_rdap_registry_map();
117 let mut tlds: Vec<String> = registry.keys().map(|k| k.to_string()).collect();
118 tlds.sort(); tlds
120}
121
122pub fn get_preset_tlds(preset: &str) -> Option<Vec<String>> {
144 match preset.to_lowercase().as_str() {
145 "startup" => Some(vec![
146 "com".to_string(),
147 "org".to_string(),
148 "io".to_string(),
149 "ai".to_string(),
150 "tech".to_string(),
151 "app".to_string(),
152 "dev".to_string(),
153 "xyz".to_string(),
154 ]),
155 "enterprise" => Some(vec![
156 "com".to_string(),
157 "org".to_string(),
158 "net".to_string(),
159 "info".to_string(),
160 "biz".to_string(),
161 "us".to_string(),
162 ]),
163 "country" => Some(vec![
164 "us".to_string(),
165 "uk".to_string(),
166 "de".to_string(),
167 "fr".to_string(),
168 "ca".to_string(),
169 "au".to_string(),
170 "jp".to_string(),
171 "br".to_string(),
172 "in".to_string(),
173 ]),
174 _ => None,
175 }
176}
177
178pub fn get_available_presets() -> Vec<&'static str> {
186 vec!["startup", "enterprise", "country"]
187}
188
189#[allow(dead_code)]
202pub fn validate_preset_tlds(preset_tlds: &[String]) -> bool {
203 let registry = get_rdap_registry_map();
204 preset_tlds
205 .iter()
206 .all(|tld| registry.contains_key(tld.as_str()))
207}
208
209pub async fn get_rdap_endpoint(tld: &str, use_bootstrap: bool) -> Result<String, DomainCheckError> {
223 let tld_lower = tld.to_lowercase();
224
225 let registry = get_rdap_registry_map();
227 if let Some(endpoint) = registry.get(tld_lower.as_str()) {
228 return Ok(endpoint.to_string());
229 }
230
231 {
233 let cache = BOOTSTRAP_CACHE
234 .lock()
235 .map_err(|_| DomainCheckError::internal("Failed to acquire bootstrap cache lock"))?;
236
237 if !cache.is_stale() {
238 if let Some(endpoint) = cache.get(&tld_lower) {
239 return Ok(endpoint);
240 }
241 }
242 }
243
244 if use_bootstrap {
246 discover_rdap_endpoint(&tld_lower).await
247 } else {
248 Err(DomainCheckError::bootstrap(
249 &tld_lower,
250 "No known RDAP endpoint and bootstrap disabled",
251 ))
252 }
253}
254
255async fn discover_rdap_endpoint(tld: &str) -> Result<String, DomainCheckError> {
268 const BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/dns.json";
269
270 let client = reqwest::Client::builder()
272 .timeout(Duration::from_secs(5))
273 .build()
274 .map_err(|e| {
275 DomainCheckError::network_with_source("Failed to create HTTP client", e.to_string())
276 })?;
277
278 let response = client.get(BOOTSTRAP_URL).send().await.map_err(|e| {
280 DomainCheckError::bootstrap(tld, format!("Failed to fetch bootstrap registry: {}", e))
281 })?;
282
283 if !response.status().is_success() {
284 return Err(DomainCheckError::bootstrap(
285 tld,
286 format!("Bootstrap registry returned HTTP {}", response.status()),
287 ));
288 }
289
290 let json: serde_json::Value = response.json().await.map_err(|e| {
291 DomainCheckError::bootstrap(tld, format!("Failed to parse bootstrap JSON: {}", e))
292 })?;
293
294 if let Some(services) = json.get("services").and_then(|s| s.as_array()) {
296 for service in services {
297 if let Some(service_array) = service.as_array() {
298 if service_array.len() >= 2 {
299 if let Some(tlds) = service_array[0].as_array() {
301 for t in tlds {
302 if let Some(t_str) = t.as_str() {
303 if t_str.to_lowercase() == tld.to_lowercase() {
304 if let Some(urls) = service_array[1].as_array() {
306 if let Some(url) = urls.first().and_then(|u| u.as_str()) {
307 let endpoint =
308 format!("{}/domain/", url.trim_end_matches('/'));
309
310 cache_discovered_endpoint(tld, &endpoint)?;
312
313 return Ok(endpoint);
314 }
315 }
316 }
317 }
318 }
319 }
320 }
321 }
322 }
323 }
324
325 Err(DomainCheckError::bootstrap(
326 tld,
327 "TLD not found in IANA bootstrap registry",
328 ))
329}
330
331fn cache_discovered_endpoint(tld: &str, endpoint: &str) -> Result<(), DomainCheckError> {
333 let mut cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
334 DomainCheckError::internal("Failed to acquire bootstrap cache lock for writing")
335 })?;
336
337 cache.insert(tld.to_string(), endpoint.to_string());
338 Ok(())
339}
340
341pub fn extract_tld(domain: &str) -> Result<String, DomainCheckError> {
354 let parts: Vec<&str> = domain.split('.').collect();
355
356 if parts.len() < 2 {
357 return Err(DomainCheckError::invalid_domain(
358 domain,
359 "Domain must contain at least one dot",
360 ));
361 }
362
363 Ok(parts.last().unwrap().to_lowercase())
367}
368
369#[allow(dead_code)]
371pub fn clear_bootstrap_cache() -> Result<(), DomainCheckError> {
372 let mut cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
373 DomainCheckError::internal("Failed to acquire bootstrap cache lock for clearing")
374 })?;
375
376 cache.endpoints.clear();
377 cache.last_update = Instant::now();
378 Ok(())
379}
380
381#[allow(dead_code)]
383pub fn get_bootstrap_cache_stats() -> Result<(usize, bool), DomainCheckError> {
384 let cache = BOOTSTRAP_CACHE.lock().map_err(|_| {
385 DomainCheckError::internal("Failed to acquire bootstrap cache lock for stats")
386 })?;
387
388 Ok((cache.endpoints.len(), cache.is_stale()))
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn test_extract_tld() {
397 assert_eq!(extract_tld("example.com").unwrap(), "com");
398 assert_eq!(extract_tld("test.org").unwrap(), "org");
399 assert_eq!(extract_tld("sub.example.com").unwrap(), "com");
400 assert!(extract_tld("invalid").is_err());
401 assert!(extract_tld("").is_err());
402 }
403
404 #[test]
405 fn test_registry_map_contains_common_tlds() {
406 let registry = get_rdap_registry_map();
407 assert!(registry.contains_key("com"));
408 assert!(registry.contains_key("org"));
409 assert!(registry.contains_key("net"));
410 assert!(registry.contains_key("io"));
411 }
412
413 #[tokio::test]
414 async fn test_get_rdap_endpoint_builtin() {
415 let endpoint = get_rdap_endpoint("com", false).await.unwrap();
416 assert!(endpoint.contains("verisign.com"));
417 }
418
419 #[tokio::test]
420 async fn test_get_rdap_endpoint_unknown_no_bootstrap() {
421 let result = get_rdap_endpoint("unknowntld123", false).await;
422 assert!(result.is_err());
423 }
424}
425
426#[cfg(test)]
427mod preset_tests {
428 use super::*;
429
430 #[test]
431 fn test_get_all_known_tlds() {
432 let tlds = get_all_known_tlds();
433
434 assert!(tlds.len() >= 30);
436 assert!(tlds.contains(&"com".to_string()));
437 assert!(tlds.contains(&"org".to_string()));
438 assert!(tlds.contains(&"io".to_string()));
439 assert!(tlds.contains(&"ai".to_string()));
440
441 let mut sorted_tlds = tlds.clone();
443 sorted_tlds.sort();
444 assert_eq!(tlds, sorted_tlds);
445 }
446
447 #[test]
448 fn test_startup_preset() {
449 let tlds = get_preset_tlds("startup").unwrap();
450
451 assert_eq!(tlds.len(), 8);
452 assert!(tlds.contains(&"com".to_string()));
453 assert!(tlds.contains(&"io".to_string()));
454 assert!(tlds.contains(&"ai".to_string()));
455 assert!(tlds.contains(&"tech".to_string()));
456
457 assert_eq!(get_preset_tlds("STARTUP"), get_preset_tlds("startup"));
459 }
460
461 #[test]
462 fn test_enterprise_preset() {
463 let tlds = get_preset_tlds("enterprise").unwrap();
464
465 assert_eq!(tlds.len(), 6);
466 assert!(tlds.contains(&"com".to_string()));
467 assert!(tlds.contains(&"org".to_string()));
468 assert!(tlds.contains(&"biz".to_string()));
469 }
470
471 #[test]
472 fn test_country_preset() {
473 let tlds = get_preset_tlds("country").unwrap();
474
475 assert_eq!(tlds.len(), 9);
476 assert!(tlds.contains(&"us".to_string()));
477 assert!(tlds.contains(&"uk".to_string()));
478 assert!(tlds.contains(&"de".to_string()));
479 }
480
481 #[test]
482 fn test_invalid_preset() {
483 assert!(get_preset_tlds("invalid").is_none());
484 assert!(get_preset_tlds("").is_none());
485 }
486
487 #[test]
488 fn test_available_presets() {
489 let presets = get_available_presets();
490 assert_eq!(presets.len(), 3);
491 assert!(presets.contains(&"startup"));
492 assert!(presets.contains(&"enterprise"));
493 assert!(presets.contains(&"country"));
494 }
495
496 #[test]
497 fn test_validate_preset_tlds() {
498 for preset_name in get_available_presets() {
500 let tlds = get_preset_tlds(preset_name).unwrap();
501 assert!(
502 validate_preset_tlds(&tlds),
503 "Preset '{}' contains TLDs without RDAP endpoints",
504 preset_name
505 );
506 }
507 }
508
509 #[test]
510 fn test_preset_tlds_subset_of_known() {
511 let all_tlds = get_all_known_tlds();
512
513 for preset_name in get_available_presets() {
514 let preset_tlds = get_preset_tlds(preset_name).unwrap();
515 for tld in preset_tlds {
516 assert!(
517 all_tlds.contains(&tld),
518 "Preset '{}' contains unknown TLD: {}",
519 preset_name,
520 tld
521 );
522 }
523 }
524 }
525}