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::{Attempt, AttemptOutcome, 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 pub explain: bool,
35 pub raw: bool,
36 pub cache_ttl: Option<Duration>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum SourcePolicy {
41 #[default]
42 Auto,
43 Registry,
44 Text,
45 Dns,
47}
48
49use crate::limit::RESOLVER_HOST;
50
51#[derive(Debug)]
52pub struct Engine {
53 client: reqwest::Client,
54 resolver: crate::lookup::resolve::Resolvers,
55 freshness: Freshness,
56 services: ServiceMap,
57 servers: Servers,
58 referral_cache: tokio::sync::Mutex<
60 std::collections::HashMap<String, Arc<tokio::sync::OnceCell<Option<String>>>>,
61 >,
62 ttl_cache: tokio::sync::Mutex<std::collections::HashMap<String, (Instant, Finding)>>,
64 pacer: Arc<Pacer>,
65 settings: Settings,
66}
67
68fn text_detail(raw: &str) -> Option<crate::lookup::Registration> {
70 let record = crate::lookup::registration::parse_text(raw);
71 (!record.is_empty()).then_some(record)
72}
73
74fn more_informative(candidate: &Reason, than: &Reason) -> bool {
76 const fn rank(reason: &Reason) -> u8 {
77 if matches!(reason, Reason::NoService) {
78 0
79 } else if reason.is_retryable() {
80 2
81 } else {
82 1
83 }
84 }
85 rank(candidate) > rank(than)
86}
87
88const fn cacheable(status: &Status) -> bool {
90 matches!(status, Status::Taken | Status::Unknown(_))
91}
92
93impl Engine {
94 pub async fn build(settings: Settings) -> Result<Self> {
95 let timeout = settings.timeout.max(LEAST_TIMEOUT);
97 let resolver = resolve::build(timeout)?;
98
99 let client = reqwest::Client::builder()
100 .user_agent(user_agent())
101 .timeout(timeout)
102 .connect_timeout(timeout)
103 .dns_resolver(resolve::HttpResolver::new(&resolver))
104 .redirect(reqwest::redirect::Policy::none())
106 .https_only(true)
107 .build()
108 .map_err(|source| crate::Error::NetworkUnreachable {
109 source: Box::new(source),
110 })?;
111
112 let skip_published = !matches!(
113 settings.source_policy,
114 SourcePolicy::Auto | SourcePolicy::Registry
115 ) || (settings.replace_servers && settings.registry_servers.is_some());
116 let (mut services, freshness) = if skip_published {
117 (ServiceMap::default(), Freshness::Cached)
118 } else {
119 ServiceMap::load(&client, &settings.cache_path, settings.refresh).await?
120 };
121 if let Some(path) = &settings.registry_servers {
122 services.merge(ServiceMap::from_file(path)?);
123 }
124
125 let text_possible = matches!(
127 settings.source_policy,
128 SourcePolicy::Auto | SourcePolicy::Text
129 );
130 let skip_bundled_servers =
131 !text_possible || (settings.replace_servers && settings.text_servers.is_some());
132 let mut servers = if skip_bundled_servers {
133 Servers::default()
134 } else {
135 Servers::bundled()?
136 };
137 if let Some(path) = &settings.text_servers {
138 servers.merge(Servers::from_file(path)?);
139 }
140
141 Ok(Self {
142 pacer: Arc::new(Pacer::new(settings.pacing.clone())),
143 servers,
144 referral_cache: tokio::sync::Mutex::new(std::collections::HashMap::new()),
145 ttl_cache: tokio::sync::Mutex::new(std::collections::HashMap::new()),
146 client,
147 resolver,
148 freshness,
149 services,
150 settings,
151 })
152 }
153
154 pub async fn check_name(&self, name: &str, suffix: &Suffix) -> Finding {
155 let domain = format!("{name}.{suffix}");
156 if domain.len() > 253 {
158 return Finding::unknown(name, suffix, Reason::NotRegistrable, Duration::ZERO);
159 }
160 if let Some(ttl) = self.settings.cache_ttl
161 && let Some(cached) = self.cached_finding(&domain, ttl).await
162 {
163 return cached;
164 }
165 let finding = self.check_name_live(name, suffix, domain.clone()).await;
166 if self.settings.cache_ttl.is_some() && cacheable(&finding.status) {
167 self.remember(domain, finding.clone()).await;
168 }
169 finding
170 }
171
172 async fn cached_finding(&self, domain: &str, ttl: Duration) -> Option<Finding> {
174 let mut cache = self.ttl_cache.lock().await;
175 match cache.get(domain) {
176 Some((at, finding)) if at.elapsed() < ttl => {
177 let mut hit = finding.clone();
178 hit.cached = true;
179 Some(hit)
180 }
181 Some(_) => {
182 cache.remove(domain);
183 None
184 }
185 None => None,
186 }
187 }
188
189 async fn remember(&self, domain: String, finding: Finding) {
190 self.ttl_cache
191 .lock()
192 .await
193 .insert(domain, (Instant::now(), finding));
194 }
195
196 fn record(
197 &self,
198 attempts: &mut Vec<Attempt>,
199 source: Source,
200 responder: Option<String>,
201 outcome: AttemptOutcome,
202 ) {
203 if self.settings.explain {
204 attempts.push(Attempt {
205 source,
206 responder,
207 outcome,
208 });
209 }
210 }
211
212 fn unknown_with(
213 &self,
214 name: &str,
215 suffix: &Suffix,
216 reason: Reason,
217 elapsed: Duration,
218 attempts: Vec<Attempt>,
219 ) -> Finding {
220 let mut finding = Finding::unknown(name, suffix, reason, elapsed);
221 if self.settings.explain {
222 finding.attempts = Some(attempts);
223 }
224 finding
225 }
226
227 async fn check_name_live(&self, name: &str, suffix: &Suffix, domain: String) -> Finding {
228 let started = Instant::now();
229 let mut reason = Reason::NoService;
231 let mut attempts: Vec<Attempt> = Vec::new();
232
233 let registry_allowed = matches!(
234 self.settings.source_policy,
235 SourcePolicy::Auto | SourcePolicy::Registry
236 );
237 if registry_allowed && let Some(services) = self.services.for_suffix(suffix.as_str()) {
238 let (answer, responder) = rdap::query(
239 &self.client,
240 &self.pacer,
241 services,
242 &domain,
243 self.settings.timeout,
244 )
245 .await;
246
247 match answer {
248 rdap::Verdict::Available(body) => {
249 self.record(
250 &mut attempts,
251 Source::Registry,
252 responder.clone(),
253 AttemptOutcome::Decisive(Status::Available),
254 );
255 return Finding {
256 domain,
257 name: name.to_owned(),
258 suffix: suffix.clone(),
259 status: Status::Available,
260 source: Some(Source::Registry),
261 elapsed: started.elapsed(),
262 responder,
263 registration: None,
264 attempts: self.settings.explain.then_some(attempts),
265 raw: if self.settings.raw {
266 body.as_deref().map(rdap::raw_text)
267 } else {
268 None
269 },
270 cached: false,
271 };
272 }
273 rdap::Verdict::Taken(body) => {
274 self.record(
275 &mut attempts,
276 Source::Registry,
277 responder.clone(),
278 AttemptOutcome::Decisive(Status::Taken),
279 );
280 return Finding {
281 domain,
282 name: name.to_owned(),
283 suffix: suffix.clone(),
284 status: Status::Taken,
285 source: Some(Source::Registry),
286 elapsed: started.elapsed(),
287 responder,
288 registration: Some(registration::parse(&body)),
289 attempts: self.settings.explain.then_some(attempts),
290 raw: self.settings.raw.then(|| rdap::raw_text(&body)),
291 cached: false,
292 };
293 }
294 rdap::Verdict::Unknown(refused) => {
295 self.record(
296 &mut attempts,
297 Source::Registry,
298 None,
299 AttemptOutcome::Inconclusive(refused.clone()),
300 );
301 if self.settings.source_policy == SourcePolicy::Registry {
302 return self.unknown_with(
303 name,
304 suffix,
305 refused,
306 started.elapsed(),
307 attempts,
308 );
309 }
310 if more_informative(&refused, &reason) {
311 reason = refused;
312 }
313 }
314 }
315 } else if self.settings.source_policy == SourcePolicy::Registry {
316 self.record(
317 &mut attempts,
318 Source::Registry,
319 None,
320 AttemptOutcome::Skipped,
321 );
322 return self.unknown_with(name, suffix, Reason::NoService, started.elapsed(), attempts);
323 } else if registry_allowed {
324 self.record(
325 &mut attempts,
326 Source::Registry,
327 None,
328 AttemptOutcome::Skipped,
329 );
330 }
331
332 let text_allowed = matches!(
334 self.settings.source_policy,
335 SourcePolicy::Auto | SourcePolicy::Text
336 );
337 if text_allowed && let Some(server) = self.servers.for_suffix(suffix.as_str()) {
338 match whois::query(
339 &self.resolver,
340 &self.pacer,
341 server,
342 &domain,
343 self.settings.timeout,
344 self.table_guard(suffix.as_str()),
345 )
346 .await
347 {
348 whois::Verdict::Available { raw } => {
349 let responder = crate::lookup::outcome::scrub(&server.host);
350 self.record(
351 &mut attempts,
352 Source::Text,
353 Some(responder.clone()),
354 AttemptOutcome::Decisive(Status::Available),
355 );
356 return Finding {
357 domain,
358 name: name.to_owned(),
359 suffix: suffix.clone(),
360 status: Status::Available,
361 source: Some(Source::Text),
362 elapsed: started.elapsed(),
363 responder: Some(responder),
364 registration: None,
365 attempts: self.settings.explain.then_some(attempts),
366 raw: self
367 .settings
368 .raw
369 .then(|| crate::lookup::outcome::scrub_unbounded(&raw)),
370 cached: false,
371 };
372 }
373 whois::Verdict::Taken { raw } => {
374 let responder = crate::lookup::outcome::scrub(&server.host);
375 self.record(
376 &mut attempts,
377 Source::Text,
378 Some(responder.clone()),
379 AttemptOutcome::Decisive(Status::Taken),
380 );
381 return Finding {
382 domain,
383 name: name.to_owned(),
384 suffix: suffix.clone(),
385 status: Status::Taken,
386 source: Some(Source::Text),
387 elapsed: started.elapsed(),
388 responder: Some(responder),
389 registration: text_detail(&raw),
390 attempts: self.settings.explain.then_some(attempts),
391 raw: self
392 .settings
393 .raw
394 .then(|| crate::lookup::outcome::scrub_unbounded(&raw)),
395 cached: false,
396 };
397 }
398 whois::Verdict::Unknown(refused) => {
399 self.record(
400 &mut attempts,
401 Source::Text,
402 Some(crate::lookup::outcome::scrub(&server.host)),
403 AttemptOutcome::Inconclusive(refused.clone()),
404 );
405 if self.settings.source_policy == SourcePolicy::Text {
406 return self.unknown_with(
407 name,
408 suffix,
409 refused,
410 started.elapsed(),
411 attempts,
412 );
413 }
414 if more_informative(&refused, &reason) {
415 reason = refused;
416 }
417 }
418 }
419 } else if text_allowed {
420 self.record(&mut attempts, Source::Text, None, AttemptOutcome::Skipped);
421 }
422
423 if text_allowed
425 && self.settings.allow_referrals
426 && let Some(found) = self
427 .referred_server(suffix)
428 .await
429 .filter(|host| crate::lookup::registry::is_public_host(host))
430 {
431 let server = Server {
432 host: found,
433 available_phrase: String::new(),
434 };
435 match whois::query(
436 &self.resolver,
437 &self.pacer,
438 &server,
439 &domain,
440 self.settings.timeout,
441 whois::HostGuard::Enforce,
442 )
443 .await
444 {
445 whois::Verdict::Available { raw } => {
446 let responder = crate::lookup::outcome::scrub(&server.host);
447 self.record(
448 &mut attempts,
449 Source::Text,
450 Some(responder.clone()),
451 AttemptOutcome::Decisive(Status::Available),
452 );
453 return Finding {
454 domain,
455 name: name.to_owned(),
456 suffix: suffix.clone(),
457 status: Status::Available,
458 source: Some(Source::Text),
459 elapsed: started.elapsed(),
460 responder: Some(responder),
461 registration: None,
462 attempts: self.settings.explain.then_some(attempts),
463 raw: self
464 .settings
465 .raw
466 .then(|| crate::lookup::outcome::scrub_unbounded(&raw)),
467 cached: false,
468 };
469 }
470 whois::Verdict::Taken { raw } => {
471 let responder = crate::lookup::outcome::scrub(&server.host);
472 self.record(
473 &mut attempts,
474 Source::Text,
475 Some(responder.clone()),
476 AttemptOutcome::Decisive(Status::Taken),
477 );
478 return Finding {
479 domain,
480 name: name.to_owned(),
481 suffix: suffix.clone(),
482 status: Status::Taken,
483 source: Some(Source::Text),
484 elapsed: started.elapsed(),
485 responder: Some(responder),
486 registration: text_detail(&raw),
487 attempts: self.settings.explain.then_some(attempts),
488 raw: self
489 .settings
490 .raw
491 .then(|| crate::lookup::outcome::scrub_unbounded(&raw)),
492 cached: false,
493 };
494 }
495 whois::Verdict::Unknown(refused) => {
496 self.record(
497 &mut attempts,
498 Source::Text,
499 Some(crate::lookup::outcome::scrub(&server.host)),
500 AttemptOutcome::Inconclusive(refused.clone()),
501 );
502 if more_informative(&refused, &reason) {
503 reason = refused;
504 }
505 }
506 }
507 }
508
509 if self.settings.source_policy == SourcePolicy::Text {
510 return self.unknown_with(name, suffix, reason, started.elapsed(), attempts);
511 }
512
513 let dns_permit = self.pacer.acquire(RESOLVER_HOST).await.ok();
515 let dns_verdict = resolve::query(&self.resolver, &domain).await;
516 drop(dns_permit);
517
518 match dns_verdict {
520 DnsVerdict::InUse => {
521 self.record(
522 &mut attempts,
523 Source::Dns,
524 None,
525 AttemptOutcome::Decisive(Status::Taken),
526 );
527 Finding {
528 domain,
529 name: name.to_owned(),
530 suffix: suffix.clone(),
531 status: Status::Taken,
532 source: Some(Source::Dns),
533 elapsed: started.elapsed(),
534 responder: None,
535 registration: None,
536 attempts: self.settings.explain.then_some(attempts),
537 raw: None,
538 cached: false,
539 }
540 }
541 DnsVerdict::Absent => {
542 self.record(
543 &mut attempts,
544 Source::Dns,
545 None,
546 AttemptOutcome::Inconclusive(reason.clone()),
547 );
548 self.unknown_with(name, suffix, reason, started.elapsed(), attempts)
549 }
550 DnsVerdict::NoAnswer => {
551 if matches!(reason, Reason::NoService) {
552 reason = Reason::Unreachable;
553 }
554 self.record(
555 &mut attempts,
556 Source::Dns,
557 None,
558 AttemptOutcome::Inconclusive(reason.clone()),
559 );
560 self.unknown_with(name, suffix, reason, started.elapsed(), attempts)
561 }
562 }
563 }
564
565 pub async fn check_domain(
567 &self,
568 catalog: &crate::tld::Catalog,
569 domain: &str,
570 ) -> Option<Finding> {
571 let (name, suffix) = catalog.split_domain(domain)?;
572 Some(self.check_name(&name, &suffix).await)
573 }
574
575 const PRUNE_SETTLED_EVERY: usize = 128;
579
580 fn already_answered_for(
581 already_answered: &std::collections::HashMap<String, Finding>,
582 name: &str,
583 suffix: &Suffix,
584 ) -> Option<Finding> {
585 already_answered.get(&format!("{name}.{suffix}")).cloned()
586 }
587
588 pub async fn sweep(
589 &self,
590 names: &[String],
591 suffixes: &[Suffix],
592 mut answered: impl FnMut(&Finding),
593 ) -> Vec<Finding> {
594 let window = self
595 .settings
596 .pacing
597 .total_concurrency
598 .max(1)
599 .saturating_mul(2);
600 let mut pending = names
601 .iter()
602 .flat_map(|name| suffixes.iter().map(move |suffix| (name, suffix)));
603
604 let mut work = FuturesUnordered::new();
605 let mut findings = Vec::new();
606 let mut already_answered: std::collections::HashMap<String, Finding> =
608 std::collections::HashMap::new();
609
610 while work.len() < window {
611 let Some((name, suffix)) = pending.next() else {
612 break;
613 };
614 if let Some(dup) = Self::already_answered_for(&already_answered, name, suffix) {
615 answered(&dup);
616 findings.push(dup);
617 continue;
618 }
619 work.push(self.check_name(name, suffix));
620 }
621 while let Some(finding) = work.next().await {
622 already_answered
623 .entry(finding.domain.clone())
624 .or_insert_with(|| finding.clone());
625 answered(&finding);
626 findings.push(finding);
627 if findings.len() % Self::PRUNE_SETTLED_EVERY == 0 {
629 self.pacer.prune_settled_hosts().await;
630 }
631 while work.len() < window {
632 let Some((name, suffix)) = pending.next() else {
633 break;
634 };
635 if let Some(dup) = Self::already_answered_for(&already_answered, name, suffix) {
636 answered(&dup);
637 findings.push(dup);
638 continue;
639 }
640 work.push(self.check_name(name, suffix));
641 break;
642 }
643 }
644
645 findings.sort_by(|a, b| a.domain.cmp(&b.domain));
646 findings
647 }
648
649 async fn referred_server(&self, suffix: &Suffix) -> Option<String> {
651 let cell = {
652 let mut cache = self.referral_cache.lock().await;
653 Arc::clone(
654 cache
655 .entry(suffix.as_str().to_owned())
656 .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new())),
657 )
658 };
659
660 cell.get_or_init(|| async {
661 referral::query(
662 &self.resolver,
663 &self.pacer,
664 suffix.as_str(),
665 self.settings.timeout,
666 )
667 .await
668 .text_host
669 })
670 .await
671 .clone()
672 }
673
674 fn table_guard(&self, suffix: &str) -> whois::HostGuard {
676 if self.servers.was_supplied(suffix) {
677 whois::HostGuard::Trusted
678 } else {
679 whois::HostGuard::Enforce
680 }
681 }
682
683 pub async fn dns_records(&self, domain: &str) -> crate::lookup::DnsRecords {
684 resolve::dns_records(&self.resolver, domain).await
685 }
686
687 #[must_use]
689 pub const fn registry_list_freshness(&self) -> Freshness {
690 self.freshness
691 }
692
693 pub async fn paused_hosts(&self) -> Vec<crate::limit::PausedHost> {
694 self.pacer.paused_hosts().await
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use tempfile::tempdir;
701
702 use super::*;
703 use crate::lookup::outcome::Tally;
704 use crate::tld::Catalog;
705
706 const UNSERVED: &str = "zzzz-no-such-extension";
707 const ALSO_UNSERVED: &str = "yyyy-no-such-extension";
708
709 fn suffix(value: &str) -> Suffix {
710 Suffix::parse(value).expect("the test suffix parses")
711 }
712
713 fn grounded_settings(source_policy: SourcePolicy, dir: &std::path::Path) -> Settings {
714 Settings {
715 pacing: PacingLimits::default(),
716 timeout: Duration::from_secs(1),
717 cache_path: dir.join("servers.json"),
718 refresh: false,
719 source_policy,
720 registry_servers: None,
721 text_servers: None,
722 replace_servers: false,
723 allow_referrals: false,
724 explain: false,
725 raw: false,
726 cache_ttl: None,
727 }
728 }
729
730 async fn text_only_engine(dir: &std::path::Path) -> Engine {
731 Engine::build(grounded_settings(SourcePolicy::Text, dir))
732 .await
733 .expect("an engine that never leaves the machine")
734 }
735
736 async fn registry_only_engine(dir: &std::path::Path) -> Engine {
737 let list = dir.join("services.json");
738 std::fs::write(
739 &list,
740 r#"{"services":[[["com"],["https://rdap.example.test/com/"]]]}"#,
741 )
742 .expect("the service list is written");
743
744 Engine::build(Settings {
745 registry_servers: Some(list),
746 replace_servers: true,
747 ..grounded_settings(SourcePolicy::Registry, dir)
748 })
749 .await
750 .expect("an engine that never leaves the machine")
751 }
752
753 #[tokio::test]
754 async fn asking_the_registry_only_reports_unknown_when_the_extension_has_no_service() {
755 let dir = tempdir().expect("temp dir");
756 let engine = registry_only_engine(dir.path()).await;
757
758 let finding = engine.check_name("example", &suffix(UNSERVED)).await;
759
760 assert_eq!(finding.status, Status::Unknown(Reason::NoService));
761 assert!(!finding.is_available());
762 assert_eq!(finding.source, None);
763 assert_eq!(finding.responder, None);
764 assert_eq!(finding.domain, format!("example.{UNSERVED}"));
765 assert_eq!(finding.name, "example");
766 }
767
768 #[tokio::test]
769 async fn the_text_protocol_alone_reports_unknown_when_no_server_answers_for_the_extension() {
770 let dir = tempdir().expect("temp dir");
771 let engine = text_only_engine(dir.path()).await;
772
773 let finding = engine.check_name("example", &suffix(UNSERVED)).await;
774
775 assert_eq!(finding.status, Status::Unknown(Reason::NoService));
776 assert!(!finding.is_available());
777 assert_eq!(finding.source, None);
778 }
779
780 #[tokio::test]
781 async fn a_sweep_nothing_can_answer_reports_every_row_unknown_and_none_free() {
782 let dir = tempdir().expect("temp dir");
783 let engine = text_only_engine(dir.path()).await;
784
785 let mut answered = 0_usize;
786 let findings = engine
787 .sweep(
788 &["beta".to_owned(), "alpha".to_owned()],
789 &[suffix(UNSERVED), suffix(ALSO_UNSERVED)],
790 |_| answered += 1,
791 )
792 .await;
793
794 assert_eq!(
795 answered,
796 findings.len(),
797 "every answer is handed to the caller as it lands"
798 );
799 assert_eq!(findings.len(), 4);
800 assert!(findings.iter().all(|finding| finding.status.is_unknown()));
801
802 let tally = Tally::of(&findings);
803 assert_eq!(tally.available, 0);
804 assert_eq!(tally.unknown, 4);
805 }
806
807 #[tokio::test]
808 async fn a_name_repeated_in_the_input_still_gets_one_row_per_occurrence() {
809 let dir = tempdir().expect("temp dir");
810 let engine = text_only_engine(dir.path()).await;
811
812 let mut answered = 0_usize;
813 let findings = engine
814 .sweep(
815 &["beta".to_owned(), "beta".to_owned(), "alpha".to_owned()],
816 &[suffix(UNSERVED)],
817 |_| answered += 1,
818 )
819 .await;
820
821 assert_eq!(findings.len(), 3, "every requested pair still gets a row");
822 assert_eq!(answered, 3, "duplicates are still reported to the caller");
823 let betas: Vec<&Finding> = findings.iter().filter(|f| f.name == "beta").collect();
824 assert_eq!(betas.len(), 2);
825 assert_eq!(betas[0].status, betas[1].status);
826 assert_eq!(betas[0].domain, betas[1].domain);
827 }
828
829 #[test]
830 fn only_a_taken_or_unknown_answer_is_ever_eligible_for_the_ttl_cache() {
831 assert!(cacheable(&Status::Taken));
832 assert!(cacheable(&Status::Unknown(Reason::NoService)));
833 assert!(
834 !cacheable(&Status::Available),
835 "a stale free reading is the one failure this tool must never hand back"
836 );
837 }
838
839 #[tokio::test]
840 async fn a_ttl_cache_reuses_an_unknown_answer_within_its_window_and_marks_it_cached() {
841 let dir = tempdir().expect("temp dir");
842 let engine = Engine::build(Settings {
843 cache_ttl: Some(Duration::from_secs(60)),
844 ..grounded_settings(SourcePolicy::Text, dir.path())
845 })
846 .await
847 .expect("an engine that never leaves the machine");
848
849 let first = engine.check_name("example", &suffix(UNSERVED)).await;
850 assert!(!first.cached);
851 assert_eq!(first.status, Status::Unknown(Reason::NoService));
852
853 let second = engine.check_name("example", &suffix(UNSERVED)).await;
854 assert!(
855 second.cached,
856 "a repeat lookup inside the TTL is served from the cache"
857 );
858 assert_eq!(second.status, first.status);
859 }
860
861 #[tokio::test]
862 async fn a_ttl_cache_entry_past_its_window_is_asked_again_rather_than_reused() {
863 let dir = tempdir().expect("temp dir");
864 let engine = Engine::build(Settings {
865 cache_ttl: Some(Duration::from_millis(1)),
866 ..grounded_settings(SourcePolicy::Text, dir.path())
867 })
868 .await
869 .expect("an engine that never leaves the machine");
870
871 let first = engine.check_name("example", &suffix(UNSERVED)).await;
872 assert!(!first.cached);
873
874 tokio::time::sleep(Duration::from_millis(20)).await;
875
876 let second = engine.check_name("example", &suffix(UNSERVED)).await;
877 assert!(
878 !second.cached,
879 "an expired entry must not be served as if it were fresh"
880 );
881 }
882
883 #[tokio::test]
884 async fn no_cache_ttl_means_no_reuse_at_all() {
885 let dir = tempdir().expect("temp dir");
886 let engine = text_only_engine(dir.path()).await;
887
888 let first = engine.check_name("example", &suffix(UNSERVED)).await;
889 let second = engine.check_name("example", &suffix(UNSERVED)).await;
890 assert!(!first.cached && !second.cached);
891 }
892
893 #[tokio::test]
894 async fn explain_records_a_skipped_registry_attempt_when_no_service_is_known() {
895 let dir = tempdir().expect("temp dir");
896 let list = dir.path().join("services.json");
897 std::fs::write(
898 &list,
899 r#"{"services":[[["com"],["https://rdap.example.test/com/"]]]}"#,
900 )
901 .expect("the service list is written");
902 let engine = Engine::build(Settings {
903 registry_servers: Some(list),
904 replace_servers: true,
905 explain: true,
906 ..grounded_settings(SourcePolicy::Registry, dir.path())
907 })
908 .await
909 .expect("an engine that never leaves the machine");
910
911 let finding = engine.check_name("example", &suffix(UNSERVED)).await;
912 let attempts = finding.attempts.expect("explain fills in the trail");
913 assert_eq!(attempts.len(), 1, "{attempts:?}");
914 assert_eq!(attempts[0].source, Source::Registry);
915 assert_eq!(attempts[0].outcome, AttemptOutcome::Skipped);
916 }
917
918 #[tokio::test]
919 async fn without_explain_the_trail_is_never_built() {
920 let dir = tempdir().expect("temp dir");
921 let engine = text_only_engine(dir.path()).await;
922
923 let finding = engine.check_name("example", &suffix(UNSERVED)).await;
924 assert_eq!(finding.attempts, None);
925 }
926
927 #[tokio::test]
928 async fn a_sweep_hands_back_one_row_per_pair_in_domain_order() {
929 let dir = tempdir().expect("temp dir");
930 let engine = text_only_engine(dir.path()).await;
931
932 let findings = engine
933 .sweep(
934 &["beta".to_owned(), "alpha".to_owned()],
935 &[suffix(UNSERVED), suffix(ALSO_UNSERVED)],
936 |_| {},
937 )
938 .await;
939
940 let domains: Vec<&str> = findings
941 .iter()
942 .map(|finding| finding.domain.as_str())
943 .collect();
944 let mut expected = domains.clone();
945 expected.sort_unstable();
946 assert_eq!(domains, expected);
947 assert_eq!(
948 domains.first(),
949 Some(&format!("alpha.{ALSO_UNSERVED}").as_str())
950 );
951 }
952
953 #[tokio::test]
954 async fn an_empty_sweep_asks_nothing_and_returns_nothing() {
955 let dir = tempdir().expect("temp dir");
956 let engine = text_only_engine(dir.path()).await;
957
958 let mut answered = 0_usize;
959 assert!(
960 engine
961 .sweep(&[], &[suffix(UNSERVED)], |_| answered += 1)
962 .await
963 .is_empty()
964 );
965 assert!(
966 engine
967 .sweep(&["alpha".to_owned()], &[], |_| answered += 1)
968 .await
969 .is_empty()
970 );
971 assert_eq!(answered, 0, "nothing to check means nothing is reported");
972 }
973
974 #[tokio::test]
975 async fn a_name_typed_in_full_is_checked_exactly_as_given() {
976 let dir = tempdir().expect("temp dir");
977 let engine = text_only_engine(dir.path()).await;
978 let catalog = Catalog::bundled().expect("the bundled catalog parses");
979
980 let finding = engine
981 .check_domain(&catalog, &format!("example.{UNSERVED}"))
982 .await
983 .expect("a domain with an extension splits");
984
985 assert_eq!(finding.domain, format!("example.{UNSERVED}"));
986 assert_eq!(finding.suffix.as_str(), UNSERVED);
987 assert!(finding.status.is_unknown());
988 }
989
990 #[tokio::test]
991 async fn a_bare_name_with_no_extension_is_not_checked_as_a_domain() {
992 let dir = tempdir().expect("temp dir");
993 let engine = text_only_engine(dir.path()).await;
994 let catalog = Catalog::bundled().expect("the bundled catalog parses");
995
996 assert!(engine.check_domain(&catalog, "example").await.is_none());
997 }
998
999 #[tokio::test]
1000 async fn a_fresh_engine_holds_no_registry_back() {
1001 let dir = tempdir().expect("temp dir");
1002 let engine = text_only_engine(dir.path()).await;
1003
1004 assert!(engine.paused_hosts().await.is_empty());
1005 }
1006
1007 #[test]
1008 fn the_default_source_uses_the_registry_first() {
1009 assert_eq!(SourcePolicy::default(), SourcePolicy::Auto);
1010 }
1011
1012 #[test]
1013 fn a_retryable_reason_is_not_buried_by_a_merely_different_one() {
1014 assert!(more_informative(&Reason::RateLimited, &Reason::NoService));
1015 assert!(!more_informative(
1016 &Reason::Malformed {
1017 detail: String::new()
1018 },
1019 &Reason::RateLimited
1020 ));
1021 assert!(more_informative(
1022 &Reason::RateLimited,
1023 &Reason::Malformed {
1024 detail: String::new()
1025 }
1026 ));
1027 assert!(!more_informative(&Reason::NoService, &Reason::RateLimited));
1028 }
1029
1030 #[test]
1031 fn settings_carry_everything_a_run_needs() {
1032 let settings = Settings {
1033 pacing: PacingLimits::default(),
1034 timeout: Duration::from_secs(10),
1035 cache_path: PathBuf::from("/tmp/reserve/servers.json"),
1036 refresh: false,
1037 source_policy: SourcePolicy::Auto,
1038 registry_servers: None,
1039 text_servers: None,
1040 replace_servers: false,
1041 allow_referrals: true,
1042 explain: false,
1043 raw: false,
1044 cache_ttl: Some(Duration::from_secs(60)),
1045 };
1046 assert_eq!(settings.timeout, Duration::from_secs(10));
1047 assert!(!settings.refresh);
1048 assert_eq!(settings.cache_ttl, Some(Duration::from_secs(60)));
1049 }
1050}