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::{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    /// @docgen DNS can prove a name is taken and never that one is free.
43    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    /// @docgen One cell per extension, so a sweep asks IANA at most once for each without the map lock spanning the network call.
56    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
63/// @docgen An empty record must stay absent, or every text answer would print a heading with nothing under it.
64fn 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
69/// @docgen A later source must not bury an earlier, more actionable reason under a merely different one.
70fn 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        // @docgen A library caller can pass anything, and a zero here made every request expire before it was sent.
86        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            // @docgen A registry that answers with a redirect could otherwise steer the request to an internal address.
95            .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        // @docgen Under a registry-only or DNS-only run nothing ever reads this table, so parsing 884 entries is startup cost for nothing.
116        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        // @docgen The 253-octet limit applies to the whole name, so two valid halves can still join into an invalid one.
147        if domain.len() > 253 {
148            return Finding::unknown(name, suffix, Reason::NotRegistrable, started.elapsed());
149        }
150        // @docgen DNS silence must never overwrite a reason a registry actually gave, so the most informative one is kept.
151        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        // @docgen For many country registries the text protocol is the only thing that answers, and it can prove a name free.
206        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        // @docgen A bundled host can go stale, so IANA is asked who serves the extension today before giving up.
257        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        // @docgen The resolver needs a permit like every other source, or the whole fan-out lands on it at once.
315        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        // @docgen A DNS miss proves nothing: a registered name that was never delegated looks identical to a free one.
320        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    /// @docgen A name the user spelled out in full is checked as given, whatever extensions the run selected.
342    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    /// @docgen The pacer bounds requests in flight, not futures allocated, so the product is streamed rather than collected.
352    /// @docgen The caller is handed each answer as it lands so a waiting person can be shown progress; the core still owns no stream.
353    /// @docgen Often enough that a long run stays flat, rarely enough that the lock is not taken on every answer.
354    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            // @docgen A sweep of the whole catalog touches hundreds of endpoints, and a settled one has nothing left worth remembering.
382            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    /// @docgen The map lock is dropped before the query, so extensions resolve in parallel while callers sharing one still wait for a single ask.
395    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    /// @docgen Only the entry the user wrote is their own choice, so trusting the whole table would cover 886 hosts they never named.
420    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    /// @docgen A run leaning on a stale list can report a whole zone unknown, and the reason for that is worth showing.
433    #[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}