1use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7const LEAST_TIMEOUT: Duration = Duration::from_secs(2);
9
10use futures::stream::{FuturesUnordered, StreamExt};
11
12use crate::error::Result;
13use crate::limit::{Pacer, PacingLimits};
14use crate::lookup::outcome::{Finding, Reason, Source, Status};
15use crate::lookup::referral;
16use crate::lookup::registry::{Freshness, ServiceMap};
17use crate::lookup::resolve::{self, DnsVerdict};
18use crate::lookup::whois::{self, Server, Servers};
19use crate::lookup::{rdap, registration};
20use crate::tld::Suffix;
21use crate::user_agent;
22
23#[derive(Debug, Clone)]
24pub struct Settings {
25 pub pacing: PacingLimits,
26 pub timeout: Duration,
27 pub cache_path: PathBuf,
28 pub refresh: bool,
29 pub source_policy: SourcePolicy,
30 pub registry_servers: Option<PathBuf>,
31 pub text_servers: Option<PathBuf>,
32 pub replace_servers: bool,
33 pub allow_referrals: bool,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum SourcePolicy {
38 #[default]
39 Auto,
40 Registry,
41 Text,
42 Dns,
44}
45
46use crate::limit::RESOLVER_HOST;
47
48#[derive(Debug)]
49pub struct Engine {
50 client: reqwest::Client,
51 resolver: crate::lookup::resolve::Resolvers,
52 freshness: Freshness,
53 services: ServiceMap,
54 servers: Servers,
55 referral_cache: tokio::sync::Mutex<
57 std::collections::HashMap<String, Arc<tokio::sync::OnceCell<Option<String>>>>,
58 >,
59 pacer: Arc<Pacer>,
60 settings: Settings,
61}
62
63fn text_detail(raw: &str) -> Option<crate::lookup::Registration> {
65 let record = crate::lookup::registration::parse_text(raw);
66 (!record.is_empty()).then_some(record)
67}
68
69fn more_informative(candidate: &Reason, than: &Reason) -> bool {
71 const fn rank(reason: &Reason) -> u8 {
72 if matches!(reason, Reason::NoService) {
73 0
74 } else if reason.is_retryable() {
75 2
76 } else {
77 1
78 }
79 }
80 rank(candidate) > rank(than)
81}
82
83impl Engine {
84 pub async fn build(settings: Settings) -> Result<Self> {
85 let timeout = settings.timeout.max(LEAST_TIMEOUT);
87 let resolver = resolve::build(timeout)?;
88
89 let client = reqwest::Client::builder()
90 .user_agent(user_agent())
91 .timeout(timeout)
92 .connect_timeout(timeout)
93 .dns_resolver(resolve::HttpResolver::new(&resolver))
94 .redirect(reqwest::redirect::Policy::none())
96 .https_only(true)
97 .build()
98 .map_err(|source| crate::Error::NetworkUnreachable {
99 source: Box::new(source),
100 })?;
101
102 let skip_published = !matches!(
103 settings.source_policy,
104 SourcePolicy::Auto | SourcePolicy::Registry
105 ) || (settings.replace_servers && settings.registry_servers.is_some());
106 let (mut services, freshness) = if skip_published {
107 (ServiceMap::default(), Freshness::Cached)
108 } else {
109 ServiceMap::load(&client, &settings.cache_path, settings.refresh).await?
110 };
111 if let Some(path) = &settings.registry_servers {
112 services.merge(ServiceMap::from_file(path)?);
113 }
114
115 let text_possible = matches!(
117 settings.source_policy,
118 SourcePolicy::Auto | SourcePolicy::Text
119 );
120 let skip_bundled_servers =
121 !text_possible || (settings.replace_servers && settings.text_servers.is_some());
122 let mut servers = if skip_bundled_servers {
123 Servers::default()
124 } else {
125 Servers::bundled()?
126 };
127 if let Some(path) = &settings.text_servers {
128 servers.merge(Servers::from_file(path)?);
129 }
130
131 Ok(Self {
132 pacer: Arc::new(Pacer::new(settings.pacing.clone())),
133 servers,
134 referral_cache: tokio::sync::Mutex::new(std::collections::HashMap::new()),
135 client,
136 resolver,
137 freshness,
138 services,
139 settings,
140 })
141 }
142
143 pub async fn check_name(&self, name: &str, suffix: &Suffix) -> Finding {
144 let started = Instant::now();
145 let domain = format!("{name}.{suffix}");
146 if domain.len() > 253 {
148 return Finding::unknown(name, suffix, Reason::NotRegistrable, started.elapsed());
149 }
150 let mut reason = Reason::NoService;
152
153 if matches!(
154 self.settings.source_policy,
155 SourcePolicy::Auto | SourcePolicy::Registry
156 ) && let Some(services) = self.services.for_suffix(suffix.as_str())
157 {
158 let (answer, responder) = rdap::query(
159 &self.client,
160 &self.pacer,
161 services,
162 &domain,
163 self.settings.timeout,
164 )
165 .await;
166
167 match answer {
168 rdap::Verdict::Available => {
169 return Finding {
170 domain,
171 name: name.to_owned(),
172 suffix: suffix.clone(),
173 status: Status::Available,
174 source: Some(Source::Registry),
175 elapsed: started.elapsed(),
176 responder,
177 registration: None,
178 };
179 }
180 rdap::Verdict::Taken(body) => {
181 return Finding {
182 domain,
183 name: name.to_owned(),
184 suffix: suffix.clone(),
185 status: Status::Taken,
186 source: Some(Source::Registry),
187 elapsed: started.elapsed(),
188 responder,
189 registration: Some(registration::parse(&body)),
190 };
191 }
192 rdap::Verdict::Unknown(refused) => {
193 if self.settings.source_policy == SourcePolicy::Registry {
194 return Finding::unknown(name, suffix, refused, started.elapsed());
195 }
196 if more_informative(&refused, &reason) {
197 reason = refused;
198 }
199 }
200 }
201 } else if self.settings.source_policy == SourcePolicy::Registry {
202 return Finding::unknown(name, suffix, Reason::NoService, started.elapsed());
203 }
204
205 let text_allowed = matches!(
207 self.settings.source_policy,
208 SourcePolicy::Auto | SourcePolicy::Text
209 );
210 if text_allowed && let Some(server) = self.servers.for_suffix(suffix.as_str()) {
211 match whois::query(
212 &self.resolver,
213 &self.pacer,
214 server,
215 &domain,
216 self.settings.timeout,
217 self.table_guard(suffix.as_str()),
218 )
219 .await
220 {
221 whois::Verdict::Available => {
222 return Finding {
223 domain,
224 name: name.to_owned(),
225 suffix: suffix.clone(),
226 status: Status::Available,
227 source: Some(Source::Text),
228 elapsed: started.elapsed(),
229 responder: Some(crate::lookup::outcome::scrub(&server.host)),
230 registration: None,
231 };
232 }
233 whois::Verdict::Taken { raw } => {
234 return Finding {
235 domain,
236 name: name.to_owned(),
237 suffix: suffix.clone(),
238 status: Status::Taken,
239 source: Some(Source::Text),
240 elapsed: started.elapsed(),
241 responder: Some(crate::lookup::outcome::scrub(&server.host)),
242 registration: text_detail(&raw),
243 };
244 }
245 whois::Verdict::Unknown(refused) => {
246 if self.settings.source_policy == SourcePolicy::Text {
247 return Finding::unknown(name, suffix, refused, started.elapsed());
248 }
249 if more_informative(&refused, &reason) {
250 reason = refused;
251 }
252 }
253 }
254 }
255
256 if text_allowed
258 && self.settings.allow_referrals
259 && let Some(found) = self
260 .referred_server(suffix)
261 .await
262 .filter(|host| crate::lookup::registry::is_public_host(host))
263 {
264 let server = Server {
265 host: found,
266 available_phrase: String::new(),
267 };
268 match whois::query(
269 &self.resolver,
270 &self.pacer,
271 &server,
272 &domain,
273 self.settings.timeout,
274 whois::HostGuard::Enforce,
275 )
276 .await
277 {
278 whois::Verdict::Available => {
279 return Finding {
280 domain,
281 name: name.to_owned(),
282 suffix: suffix.clone(),
283 status: Status::Available,
284 source: Some(Source::Text),
285 elapsed: started.elapsed(),
286 responder: Some(crate::lookup::outcome::scrub(&server.host)),
287 registration: None,
288 };
289 }
290 whois::Verdict::Taken { raw } => {
291 return Finding {
292 domain,
293 name: name.to_owned(),
294 suffix: suffix.clone(),
295 status: Status::Taken,
296 source: Some(Source::Text),
297 elapsed: started.elapsed(),
298 responder: Some(crate::lookup::outcome::scrub(&server.host)),
299 registration: text_detail(&raw),
300 };
301 }
302 whois::Verdict::Unknown(refused) => {
303 if more_informative(&refused, &reason) {
304 reason = refused;
305 }
306 }
307 }
308 }
309
310 if self.settings.source_policy == SourcePolicy::Text {
311 return Finding::unknown(name, suffix, reason, started.elapsed());
312 }
313
314 let dns_permit = self.pacer.acquire(RESOLVER_HOST).await.ok();
316 let dns_verdict = resolve::query(&self.resolver, &domain).await;
317 drop(dns_permit);
318
319 match dns_verdict {
321 DnsVerdict::InUse => Finding {
322 domain,
323 name: name.to_owned(),
324 suffix: suffix.clone(),
325 status: Status::Taken,
326 source: Some(Source::Dns),
327 elapsed: started.elapsed(),
328 responder: None,
329 registration: None,
330 },
331 DnsVerdict::Absent => Finding::unknown(name, suffix, reason, started.elapsed()),
332 DnsVerdict::NoAnswer => {
333 if matches!(reason, Reason::NoService) {
334 reason = Reason::Unreachable;
335 }
336 Finding::unknown(name, suffix, reason, started.elapsed())
337 }
338 }
339 }
340
341 pub async fn check_domain(
343 &self,
344 catalog: &crate::tld::Catalog,
345 domain: &str,
346 ) -> Option<Finding> {
347 let (name, suffix) = catalog.split_domain(domain)?;
348 Some(self.check_name(&name, &suffix).await)
349 }
350
351 const PRUNE_SETTLED_EVERY: usize = 128;
355
356 pub async fn sweep(
357 &self,
358 names: &[String],
359 suffixes: &[Suffix],
360 mut answered: impl FnMut(&Finding),
361 ) -> Vec<Finding> {
362 let window = self
363 .settings
364 .pacing
365 .total_concurrency
366 .max(1)
367 .saturating_mul(2);
368 let mut pending = names
369 .iter()
370 .flat_map(|name| suffixes.iter().map(move |suffix| (name, suffix)));
371
372 let mut work = FuturesUnordered::new();
373 let mut findings = Vec::new();
374
375 for (name, suffix) in pending.by_ref().take(window) {
376 work.push(self.check_name(name, suffix));
377 }
378 while let Some(finding) = work.next().await {
379 answered(&finding);
380 findings.push(finding);
381 if findings.len() % Self::PRUNE_SETTLED_EVERY == 0 {
383 self.pacer.prune_settled_hosts().await;
384 }
385 if let Some((name, suffix)) = pending.next() {
386 work.push(self.check_name(name, suffix));
387 }
388 }
389
390 findings.sort_by(|a, b| a.domain.cmp(&b.domain));
391 findings
392 }
393
394 async fn referred_server(&self, suffix: &Suffix) -> Option<String> {
396 let cell = {
397 let mut cache = self.referral_cache.lock().await;
398 Arc::clone(
399 cache
400 .entry(suffix.as_str().to_owned())
401 .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new())),
402 )
403 };
404
405 cell.get_or_init(|| async {
406 referral::query(
407 &self.resolver,
408 &self.pacer,
409 suffix.as_str(),
410 self.settings.timeout,
411 )
412 .await
413 .text_host
414 })
415 .await
416 .clone()
417 }
418
419 fn table_guard(&self, suffix: &str) -> whois::HostGuard {
421 if self.servers.was_supplied(suffix) {
422 whois::HostGuard::Trusted
423 } else {
424 whois::HostGuard::Enforce
425 }
426 }
427
428 pub async fn dns_records(&self, domain: &str) -> crate::lookup::DnsRecords {
429 resolve::dns_records(&self.resolver, domain).await
430 }
431
432 #[must_use]
434 pub const fn registry_list_freshness(&self) -> Freshness {
435 self.freshness
436 }
437
438 pub async fn paused_hosts(&self) -> Vec<crate::limit::PausedHost> {
439 self.pacer.paused_hosts().await
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 use tempfile::tempdir;
446
447 use super::*;
448 use crate::lookup::outcome::Tally;
449 use crate::tld::Catalog;
450
451 const UNSERVED: &str = "zzzz-no-such-extension";
452 const ALSO_UNSERVED: &str = "yyyy-no-such-extension";
453
454 fn suffix(value: &str) -> Suffix {
455 Suffix::parse(value).expect("the test suffix parses")
456 }
457
458 fn grounded_settings(source_policy: SourcePolicy, dir: &std::path::Path) -> Settings {
459 Settings {
460 pacing: PacingLimits::default(),
461 timeout: Duration::from_secs(1),
462 cache_path: dir.join("servers.json"),
463 refresh: false,
464 source_policy,
465 registry_servers: None,
466 text_servers: None,
467 replace_servers: false,
468 allow_referrals: false,
469 }
470 }
471
472 async fn text_only_engine(dir: &std::path::Path) -> Engine {
473 Engine::build(grounded_settings(SourcePolicy::Text, dir))
474 .await
475 .expect("an engine that never leaves the machine")
476 }
477
478 async fn registry_only_engine(dir: &std::path::Path) -> Engine {
479 let list = dir.join("services.json");
480 std::fs::write(
481 &list,
482 r#"{"services":[[["com"],["https://rdap.example.test/com/"]]]}"#,
483 )
484 .expect("the service list is written");
485
486 Engine::build(Settings {
487 registry_servers: Some(list),
488 replace_servers: true,
489 ..grounded_settings(SourcePolicy::Registry, dir)
490 })
491 .await
492 .expect("an engine that never leaves the machine")
493 }
494
495 #[tokio::test]
496 async fn asking_the_registry_only_reports_unknown_when_the_extension_has_no_service() {
497 let dir = tempdir().expect("temp dir");
498 let engine = registry_only_engine(dir.path()).await;
499
500 let finding = engine.check_name("example", &suffix(UNSERVED)).await;
501
502 assert_eq!(finding.status, Status::Unknown(Reason::NoService));
503 assert!(!finding.is_available());
504 assert_eq!(finding.source, None);
505 assert_eq!(finding.responder, None);
506 assert_eq!(finding.domain, format!("example.{UNSERVED}"));
507 assert_eq!(finding.name, "example");
508 }
509
510 #[tokio::test]
511 async fn the_text_protocol_alone_reports_unknown_when_no_server_answers_for_the_extension() {
512 let dir = tempdir().expect("temp dir");
513 let engine = text_only_engine(dir.path()).await;
514
515 let finding = engine.check_name("example", &suffix(UNSERVED)).await;
516
517 assert_eq!(finding.status, Status::Unknown(Reason::NoService));
518 assert!(!finding.is_available());
519 assert_eq!(finding.source, None);
520 }
521
522 #[tokio::test]
523 async fn a_sweep_nothing_can_answer_reports_every_row_unknown_and_none_free() {
524 let dir = tempdir().expect("temp dir");
525 let engine = text_only_engine(dir.path()).await;
526
527 let mut answered = 0_usize;
528 let findings = engine
529 .sweep(
530 &["beta".to_owned(), "alpha".to_owned()],
531 &[suffix(UNSERVED), suffix(ALSO_UNSERVED)],
532 |_| answered += 1,
533 )
534 .await;
535
536 assert_eq!(
537 answered,
538 findings.len(),
539 "every answer is handed to the caller as it lands"
540 );
541 assert_eq!(findings.len(), 4);
542 assert!(findings.iter().all(|finding| finding.status.is_unknown()));
543
544 let tally = Tally::of(&findings);
545 assert_eq!(tally.available, 0);
546 assert_eq!(tally.unknown, 4);
547 }
548
549 #[tokio::test]
550 async fn a_sweep_hands_back_one_row_per_pair_in_domain_order() {
551 let dir = tempdir().expect("temp dir");
552 let engine = text_only_engine(dir.path()).await;
553
554 let findings = engine
555 .sweep(
556 &["beta".to_owned(), "alpha".to_owned()],
557 &[suffix(UNSERVED), suffix(ALSO_UNSERVED)],
558 |_| {},
559 )
560 .await;
561
562 let domains: Vec<&str> = findings
563 .iter()
564 .map(|finding| finding.domain.as_str())
565 .collect();
566 let mut expected = domains.clone();
567 expected.sort_unstable();
568 assert_eq!(domains, expected);
569 assert_eq!(
570 domains.first(),
571 Some(&format!("alpha.{ALSO_UNSERVED}").as_str())
572 );
573 }
574
575 #[tokio::test]
576 async fn an_empty_sweep_asks_nothing_and_returns_nothing() {
577 let dir = tempdir().expect("temp dir");
578 let engine = text_only_engine(dir.path()).await;
579
580 let mut answered = 0_usize;
581 assert!(
582 engine
583 .sweep(&[], &[suffix(UNSERVED)], |_| answered += 1)
584 .await
585 .is_empty()
586 );
587 assert!(
588 engine
589 .sweep(&["alpha".to_owned()], &[], |_| answered += 1)
590 .await
591 .is_empty()
592 );
593 assert_eq!(answered, 0, "nothing to check means nothing is reported");
594 }
595
596 #[tokio::test]
597 async fn a_name_typed_in_full_is_checked_exactly_as_given() {
598 let dir = tempdir().expect("temp dir");
599 let engine = text_only_engine(dir.path()).await;
600 let catalog = Catalog::bundled().expect("the bundled catalog parses");
601
602 let finding = engine
603 .check_domain(&catalog, &format!("example.{UNSERVED}"))
604 .await
605 .expect("a domain with an extension splits");
606
607 assert_eq!(finding.domain, format!("example.{UNSERVED}"));
608 assert_eq!(finding.suffix.as_str(), UNSERVED);
609 assert!(finding.status.is_unknown());
610 }
611
612 #[tokio::test]
613 async fn a_bare_name_with_no_extension_is_not_checked_as_a_domain() {
614 let dir = tempdir().expect("temp dir");
615 let engine = text_only_engine(dir.path()).await;
616 let catalog = Catalog::bundled().expect("the bundled catalog parses");
617
618 assert!(engine.check_domain(&catalog, "example").await.is_none());
619 }
620
621 #[tokio::test]
622 async fn a_fresh_engine_holds_no_registry_back() {
623 let dir = tempdir().expect("temp dir");
624 let engine = text_only_engine(dir.path()).await;
625
626 assert!(engine.paused_hosts().await.is_empty());
627 }
628
629 #[test]
630 fn the_default_source_uses_the_registry_first() {
631 assert_eq!(SourcePolicy::default(), SourcePolicy::Auto);
632 }
633
634 #[test]
635 fn a_retryable_reason_is_not_buried_by_a_merely_different_one() {
636 assert!(more_informative(&Reason::RateLimited, &Reason::NoService));
637 assert!(!more_informative(
638 &Reason::Malformed {
639 detail: String::new()
640 },
641 &Reason::RateLimited
642 ));
643 assert!(more_informative(
644 &Reason::RateLimited,
645 &Reason::Malformed {
646 detail: String::new()
647 }
648 ));
649 assert!(!more_informative(&Reason::NoService, &Reason::RateLimited));
650 }
651
652 #[test]
653 fn settings_carry_everything_a_run_needs() {
654 let settings = Settings {
655 pacing: PacingLimits::default(),
656 timeout: Duration::from_secs(10),
657 cache_path: PathBuf::from("/tmp/reserve/servers.json"),
658 refresh: false,
659 source_policy: SourcePolicy::Auto,
660 registry_servers: None,
661 text_servers: None,
662 replace_servers: false,
663 allow_referrals: true,
664 };
665 assert_eq!(settings.timeout, Duration::from_secs(10));
666 assert!(!settings.refresh);
667 }
668}