Skip to main content

reserve_core/lookup/
engine.rs

1//! Running a sweep. The registry is asked first because only it can prove a name free.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7/// @docgen The DNS side already refuses to go below this, and a request that expires before it is sent answers nothing.
8const 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    /// @docgen DNS can prove a name is taken and never that one is free.
46    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    /// @docgen One cell per extension, so a sweep asks IANA at most once for each without the map lock spanning the network call.
59    referral_cache: tokio::sync::Mutex<
60        std::collections::HashMap<String, Arc<tokio::sync::OnceCell<Option<String>>>>,
61    >,
62    /// @docgen Only `--cache-ttl` fills this; see `cacheable()` for what it is never allowed to hold.
63    ttl_cache: tokio::sync::Mutex<std::collections::HashMap<String, (Instant, Finding)>>,
64    pacer: Arc<Pacer>,
65    settings: Settings,
66}
67
68/// @docgen An empty record must stay absent, or every text answer would print a heading with nothing under it.
69fn 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
74/// @docgen A later source must not bury an earlier, more actionable reason under a merely different one.
75fn 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
88/// @docgen A stale free reading is the one answer this tool must never hand back, so only a settled question is remembered.
89const 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        // @docgen A library caller can pass anything, and a zero here made every request expire before it was sent.
96        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            // @docgen A registry that answers with a redirect could otherwise steer the request to an internal address.
105            .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        // @docgen Under a registry-only or DNS-only run nothing ever reads this table, so parsing 884 entries is startup cost for nothing.
126        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        // @docgen The 253-octet limit applies to the whole name, so two valid halves can still join into an invalid one.
157        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    /// @docgen A hit past its TTL is worse than a miss, so an expired row is evicted rather than handed back.
173    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        // @docgen DNS silence must never overwrite a reason a registry actually gave, so the most informative one is kept.
230        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        // @docgen For many country registries the text protocol is the only thing that answers, and it can prove a name free.
333        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        // @docgen A bundled host can go stale, so IANA is asked who serves the extension today before giving up.
424        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        // @docgen The resolver needs a permit like every other source, or the whole fan-out lands on it at once.
514        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        // @docgen A DNS miss proves nothing: a registered name that was never delegated looks identical to a free one.
519        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    /// @docgen A name the user spelled out in full is checked as given, whatever extensions the run selected.
566    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    /// @docgen The pacer bounds requests in flight, not futures allocated, so the product is streamed rather than collected.
576    /// @docgen The caller is handed each answer as it lands so a waiting person can be shown progress; the core still owns no stream.
577    /// @docgen Often enough that a long run stays flat, rarely enough that the lock is not taken on every answer.
578    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        // @docgen A name repeated in the input, on the line or in --names-from, is answered once and copied, never asked twice.
607        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            // @docgen A sweep of the whole catalog touches hundreds of endpoints, and a settled one has nothing left worth remembering.
628            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    /// @docgen The map lock is dropped before the query, so extensions resolve in parallel while callers sharing one still wait for a single ask.
650    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    /// @docgen Only the entry the user wrote is their own choice, so trusting the whole table would cover 886 hosts they never named.
675    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    /// @docgen A run leaning on a stale list can report a whole zone unknown, and the reason for that is worth showing.
688    #[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}