Skip to main content

seer_core/dns/
resolver.rs

1//! DNS resolution over hickory-resolver.
2//!
3//! [`DnsResolver::resolve`] — and every feature built on it — looks records
4//! up through hickory's resolver. [`DnsResolver::query`] reports the response
5//! itself, as dig does, so it sends one query per record type straight to the
6//! same upstream servers through `dns::transport` (which says why the
7//! resolver cannot), except for the special-use names the resolver answers
8//! without sending anything.
9//!
10//! Retry boundary (deliberate): unlike the WHOIS/RDAP clients, this module
11//! does NOT wrap queries in [`crate::retry::RetryPolicy`]. hickory-resolver
12//! already re-sends a lookup whose attempt timed out, up to `opts.attempts`
13//! ([`RESOLVER_ATTEMPTS`]) attempts in all, each under its own full
14//! per-query timeout — so a lookup against a silent server takes that many
15//! timeouts. Stacking an outer retry loop on top would multiply worst-case
16//! latency without improving resolution odds. If a retry knob is ever
17//! needed here, tune [`RESOLVER_ATTEMPTS`] rather than adding a wrapper;
18//! `query()`'s direct path derives its deadline from it
19//! (`transport::QUERY_BUDGET_TIMEOUTS`).
20//!
21//! One follow-up is not a retry: hickory-resolver 0.26.3 can give up on an
22//! answer too big for UDP without asking over TCP, so a lookup that ends
23//! truncated is asked once more over TCP of the same servers
24//! ([`lookup_with_tcp_fallback`], shared with the DNSSEC checker), as a
25//! truncated reply requires.
26
27use std::borrow::Cow;
28use std::collections::HashMap;
29use std::net::IpAddr;
30use std::pin::pin;
31use std::str::FromStr;
32use std::sync::Arc;
33use std::time::{Duration, Instant};
34
35use futures::future::{self, Either};
36use hickory_resolver::config::{
37    ConnectionConfig, NameServerConfig, ProtocolConfig, ResolveHosts, ResolverConfig, ResolverOpts,
38    ServerOrderingStrategy, GOOGLE,
39};
40use hickory_resolver::lookup::Lookup;
41use hickory_resolver::net::runtime::TokioRuntimeProvider;
42use hickory_resolver::net::NetError;
43use hickory_resolver::proto::dnssec::PublicKey;
44use hickory_resolver::proto::rr::rdata::svcb::{SvcParamKey, SvcParamValue, SVCB};
45use hickory_resolver::proto::rr::rdata::CAA;
46use hickory_resolver::proto::rr::{
47    Name, RData as HickoryRData, Record, RecordType as HickoryRecordType,
48};
49use hickory_resolver::proto::serialize::binary::BinEncodable;
50use hickory_resolver::TokioResolver;
51use tracing::{debug, instrument};
52
53use super::nameserver::{NameserverProtocol, NameserverSpec};
54use super::query::{
55    answered_locally, dedupe_records, fold_any, merge_any, random_probe_label, wildcard_outcome,
56    wildcard_probe_name, DnsQueryResult, Exchange, SubQuery, WildcardProbe,
57};
58use super::records::{DnsRecord, RecordData, RecordType, SvcParam};
59use super::transport::{NoResponse, Transport};
60use super::DEFAULT_DNS_TIMEOUT;
61use crate::error::{Result, SeerError};
62use crate::validation::{normalize_domain, normalize_query_name};
63
64/// hickory's `attempts`: how many times a lookup is sent before it fails,
65/// each attempt under a full per-query timeout. The one setting both query
66/// paths' deadlines derive from (see the module docs).
67pub(crate) const RESOLVER_ATTEMPTS: usize = 2;
68
69/// How long a positive answer waits for its still-running wildcard probe,
70/// as a fraction of the per-query timeout (1s at the 5s default). The probe
71/// asks for a random, never-cached name, so it can trail a cached answer by
72/// a full recursion — but never by more than this: it must not cost the
73/// main query its answer time.
74const PROBE_GRACE_DIVISOR: u32 = 5;
75
76/// How debug logs name the upstream of a query that names no nameserver.
77const DEFAULT_UPSTREAM: &str = "default (Google Public DNS)";
78
79/// The record types an `ANY` query fans out to, in report order.
80///
81/// `ANY` is not sent on the wire: RFC 8482 lets servers answer it minimally
82/// (typically one HINFO or a single RRset), so seer queries these types
83/// concurrently instead. One list for [`DnsResolver::resolve`] and
84/// [`DnsResolver::query`], so the two cannot drift.
85const ANY_TYPES: [RecordType; 11] = [
86    RecordType::A,
87    RecordType::AAAA,
88    RecordType::CNAME,
89    RecordType::MX,
90    RecordType::NS,
91    RecordType::TXT,
92    RecordType::SOA,
93    RecordType::CAA,
94    RecordType::HTTPS,
95    RecordType::DS,
96    RecordType::DNSKEY,
97];
98
99/// Apply seer's standard resolver options.
100///
101/// Extracted from [`build_resolver`] so the option set is assertable without
102/// standing up a resolver (hickory exposes no getter for a built resolver's
103/// options), and shared with `net.rs`'s SSRF fallback resolver so the two
104/// cannot drift — they previously carried hand-copied option sets and the
105/// server-ordering fix below would have landed in only one of them.
106pub(crate) fn apply_standard_opts(opts: &mut ResolverOpts, timeout: Duration) {
107    opts.timeout = timeout;
108    opts.attempts = RESOLVER_ATTEMPTS;
109    opts.use_hosts_file = ResolveHosts::Never;
110    // Pin the query order to the configured list instead of hickory's default
111    // `QueryStatistics`. The default orders servers by observed performance,
112    // which is the right call for a long-lived process but meaningless in a
113    // short-lived CLI run that has collected no statistics yet — leaving the
114    // effective order arbitrary.
115    //
116    // That mattered because the default upstream group (`GOOGLE`) carries two
117    // IPv4 and two IPv6 addresses, and hickory queries `num_concurrent_reqs`
118    // (2) of them in parallel. On a host advertising an IPv6 default route
119    // with no working IPv6 transit — RA-advertised IPv6 that black-holes,
120    // common on consumer networks — a pair that happened to be both IPv6 sent
121    // into the void and burned the full per-query timeout. Roughly one run in
122    // six stalled 5s, and `seer doctor` reported an outright DNS failure
123    // because its probe deadline equals that timeout, leaving no budget to
124    // fail over.
125    //
126    // With the order pinned, the parallel pair is deterministically the two
127    // IPv4 servers. IPv6 entries stay in the list and remain reachable as
128    // fallback: on a genuinely IPv6-only host the IPv4 sends fail immediately
129    // with ENETUNREACH rather than timing out, so failover stays fast.
130    opts.server_ordering_strategy = ServerOrderingStrategy::UserProvidedOrder;
131}
132
133/// Build a TokioResolver pre-configured with the given upstream config and
134/// our standard options (timeout, retries, no hosts-file consultation).
135///
136/// Build only fails when hickory cannot construct its rustls TLS context
137/// (needed for DoT/DoH upstreams). With the `webpki-roots` feature supplying
138/// the root store, that construction is infallible in practice, but the
139/// fallible signature is kept honest so a future root-store change degrades
140/// to a typed error instead of a panic.
141fn build_resolver(config: ResolverConfig, timeout: Duration) -> Result<TokioResolver> {
142    let mut builder = TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
143    apply_standard_opts(builder.options_mut(), timeout);
144    builder
145        .build()
146        .map_err(|e| SeerError::DnsError(format!("failed to construct DNS resolver: {}", e)))
147}
148
149/// [`build_resolver`] for a plain UDP/TCP upstream config — the default
150/// upstream, or the DNSSEC and delegation checkers' recursive reads.
151///
152/// The `expect` expresses an invariant rather than laziness: building only
153/// fails on hickory's rustls TLS context, which a plain UDP/TCP config never
154/// needs, and the bundled webpki root store makes it infallible besides.
155pub(crate) fn build_plain_resolver(config: ResolverConfig, timeout: Duration) -> TokioResolver {
156    build_resolver(config, timeout)
157        .expect("plain UDP/TCP resolver build cannot fail with the bundled webpki root store")
158}
159
160/// `resolver`'s lookup of `name`, asked once more over TCP of the same
161/// servers (`config`, the upstream it was built from) when it ends with a
162/// truncated answer. Every hickory-resolver lookup that may draw a large
163/// answer goes through here: [`DnsResolver::resolve`] and the DNSSEC
164/// checker's DS/DNSKEY reads.
165///
166/// hickory-resolver 0.26.3 queries two servers in parallel. When the answer
167/// is too big for UDP, the first truncated reply switches the request to
168/// TCP, and the second server's truncated UDP reply, still in flight, is
169/// then taken for a truncated TCP reply: hickory gives up without asking
170/// over TCP. A truncated reply calls for exactly that TCP query (RFC 7766
171/// §5), so this sends it — once, to the same servers. Remove once hickory
172/// fixes the race (the
173/// `hickory_still_abandons_truncated_parallel_udp_replies` test fails then).
174pub(crate) async fn lookup_with_tcp_fallback(
175    resolver: &TokioResolver,
176    config: &ResolverConfig,
177    timeout: Duration,
178    name: &str,
179    wire_type: HickoryRecordType,
180) -> std::result::Result<Lookup, NetError> {
181    match resolver.lookup(name, wire_type).await {
182        Err(NetError::Truncated) => {
183            debug!(name, "truncated answer abandoned over UDP; asking over TCP");
184            match build_resolver(tcp_only(config), timeout) {
185                Ok(tcp) => tcp.lookup(name, wire_type).await,
186                Err(e) => {
187                    debug!(error = %e, "cannot build the TCP resolver");
188                    Err(NetError::Truncated)
189                }
190            }
191        }
192        other => other,
193    }
194}
195
196/// The resolver a [`DnsResolver::resolve`] call runs on, with the upstream
197/// config it was built from (kept so a truncated answer can be asked again
198/// over TCP of the same servers: see [`tcp_only`]).
199struct ResolveTarget<'a> {
200    resolver: Cow<'a, TokioResolver>,
201    config: ResolverConfig,
202}
203
204/// How long a resolver built for a custom nameserver is reused (see
205/// [`CustomResolvers`]). Short, so a nameserver given by hostname — a DoT or
206/// DoH spec, typically — is re-resolved and its addresses re-vetted at least
207/// this often in a long-lived process (REPL, TUI, seer-py, the API).
208const CUSTOM_RESOLVER_TTL: Duration = Duration::from_secs(60);
209
210/// The most custom-nameserver resolvers kept at once; the oldest is dropped
211/// to make room, so a process fed many specs (the API) stays bounded.
212const MAX_CUSTOM_RESOLVERS: usize = 32;
213
214/// One spec's resolver, installed by the first build through it to finish.
215/// Lookups racing to fill it each build their own and then use whichever
216/// was installed first, so concurrent lookups (posture's) share one record
217/// cache from the start. Nothing waits on another's build: a slow or failing
218/// one (a hostname spec that times out) would otherwise be repeated by each
219/// waiter in turn.
220type CustomSlot = Arc<std::sync::Mutex<Option<(TokioResolver, ResolverConfig)>>>;
221
222/// Resolvers built for custom nameserver specs, shared by a [`DnsResolver`]
223/// and its clones. A resolver built per lookup started with an empty record
224/// cache and connection pool, so posture, CAA or a bulk run through one
225/// nameserver re-asked it for every repeated name (and a DoT/DoH spec paid a
226/// handshake per lookup) where the default upstream answered from cache.
227///
228/// Each entry is reused for [`CUSTOM_RESOLVER_TTL`] after it was created, at
229/// most [`MAX_CUSTOM_RESOLVERS`] at once. Keyed by the spec string: the port
230/// override, timeout and SSRF setting are fixed per `DnsResolver`, and the
231/// builder methods that change them start a new memo.
232#[derive(Default)]
233struct CustomResolvers {
234    slots: std::sync::Mutex<HashMap<String, (Instant, CustomSlot)>>,
235}
236
237impl CustomResolvers {
238    /// The slot for `spec`: its live one, or a new empty one (replacing an
239    /// expired one, after dropping every expired entry and, when still full,
240    /// the oldest).
241    fn slot(&self, spec: &str) -> CustomSlot {
242        let now = Instant::now();
243        // A panic while the map was held leaves it consistent (no partial
244        // writes), so a poisoned lock is safe to keep using.
245        let mut slots = self.slots.lock().unwrap_or_else(|e| e.into_inner());
246        if let Some((created, slot)) = slots.get(spec) {
247            if now.duration_since(*created) < CUSTOM_RESOLVER_TTL {
248                return slot.clone();
249            }
250        }
251        slots.retain(|_, (created, _)| now.duration_since(*created) < CUSTOM_RESOLVER_TTL);
252        if slots.len() >= MAX_CUSTOM_RESOLVERS {
253            let oldest = slots
254                .iter()
255                .min_by_key(|(_, (created, _))| *created)
256                .map(|(key, _)| key.clone());
257            if let Some(oldest) = oldest {
258                slots.remove(&oldest);
259            }
260        }
261        let slot = CustomSlot::default();
262        slots.insert(spec.to_string(), (now, slot.clone()));
263        slot
264    }
265}
266
267/// `config` with every UDP connection replaced by TCP on the same port.
268/// Encrypted transports are kept as they are: they never truncate.
269fn tcp_only(config: &ResolverConfig) -> ResolverConfig {
270    let mut tcp =
271        ResolverConfig::from_parts(config.domain().cloned(), config.search().to_vec(), vec![]);
272    for server in config.name_servers() {
273        let mut server = server.clone();
274        let udp_ports: Vec<u16> = server
275            .connections
276            .iter()
277            .filter(|c| matches!(c.protocol, ProtocolConfig::Udp))
278            .map(|c| c.port)
279            .collect();
280        server
281            .connections
282            .retain(|c| !matches!(c.protocol, ProtocolConfig::Udp));
283        for port in udp_ports {
284            let has_tcp = server
285                .connections
286                .iter()
287                .any(|c| matches!(c.protocol, ProtocolConfig::Tcp) && c.port == port);
288            if !has_tcp {
289                let mut connection = ConnectionConfig::tcp();
290                connection.port = port;
291                server.connections.push(connection);
292            }
293        }
294        tcp.add_name_server(server);
295    }
296    tcp
297}
298
299/// Build the default (Google DNS over UDP/TCP) resolver.
300///
301/// Infallible (see [`build_plain_resolver`]): the `new()`/`with_timeout()`
302/// constructors predate DoT/DoH support.
303fn build_default_resolver(timeout: Duration) -> TokioResolver {
304    build_plain_resolver(default_upstream_config(), timeout)
305}
306
307/// The default upstream: Google Public DNS over UDP with TCP fallback, IPv4
308/// servers first. The one definition for the default resolver and for
309/// [`DnsResolver::query`]'s direct queries.
310fn default_upstream_config() -> ResolverConfig {
311    ResolverConfig::udp_and_tcp(&GOOGLE)
312}
313
314/// Upstream config pinned to the single server `ip:port` — UDP only, or UDP
315/// with TCP fallback (for answers that may truncate).
316pub(crate) fn single_server_config(ip: IpAddr, port: u16, tcp_fallback: bool) -> ResolverConfig {
317    let mut ns = if tcp_fallback {
318        NameServerConfig::udp_and_tcp(ip)
319    } else {
320        NameServerConfig::udp(ip)
321    };
322    for connection in &mut ns.connections {
323        connection.port = port;
324    }
325    let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
326    config.add_name_server(ns);
327    config
328}
329
330/// Google DNS (UDP+TCP), or the pinned UDP `upstream` that the DNSSEC and
331/// delegation checkers' `#[cfg(test)]` seams point at a loopback mock.
332pub(crate) fn google_or_pinned(upstream: Option<(IpAddr, u16)>) -> ResolverConfig {
333    match upstream {
334        None => default_upstream_config(),
335        Some((ip, port)) => single_server_config(ip, port, false),
336    }
337}
338
339/// Appends the root dot so hickory treats `name` as fully qualified (no
340/// search-list expansion).
341pub(crate) fn fqdn(name: &str) -> String {
342    if name.ends_with('.') {
343        name.to_string()
344    } else {
345        format!("{name}.")
346    }
347}
348
349/// Build the hickory upstream config for a parsed nameserver spec and its
350/// resolved (and already SSRF-validated) addresses.
351///
352/// One `NameServerConfig` is added per IP (IPv4 first, see below), all
353/// speaking the spec's protocol on the spec's port. For DoT/DoH the TLS
354/// server name is the spec's host — the hostname when one was given, or the
355/// IP literal itself (verified against the certificate's IP SANs, which the
356/// major public resolvers carry). `port_override` is the `#[cfg(test)]`
357/// mock-server seam and is always `None` in production.
358fn build_upstream_config(
359    spec: &NameserverSpec,
360    ips: &[IpAddr],
361    port_override: Option<u16>,
362) -> ResolverConfig {
363    let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
364    let port = port_override.unwrap_or(spec.port);
365    // IPv4 before IPv6, each family keeping its resolved order — the same
366    // shape as the default `GOOGLE` group. The pool is pinned to
367    // `UserProvidedOrder` (see `apply_standard_opts`) and races its first
368    // `num_concurrent_reqs` (2) entries under one deadline equal to the
369    // per-query timeout, so list order decides which addresses get tried.
370    // hickory's `lookup_ip` returns AAAA before A, so a dual-stack hostname
371    // (`dns.google`, `https://cloudflare-dns.com/dns-query`) led with two
372    // IPv6 entries; with black-holed IPv6 transit they spent the whole
373    // deadline and the IPv4 entries were never reached. IPv6 stays as
374    // fallback: on an IPv6-only host IPv4 sends fail fast (ENETUNREACH).
375    let mut ordered = ips.to_vec();
376    crate::net::ipv4_first(&mut ordered, |ip| *ip);
377    for ip in ordered {
378        let mut ns = match spec.protocol {
379            NameserverProtocol::Udp => NameServerConfig::udp(ip),
380            NameserverProtocol::Tls => NameServerConfig::tls(ip, Arc::from(spec.tls_name())),
381            NameserverProtocol::Https => NameServerConfig::https(
382                ip,
383                Arc::from(spec.tls_name()),
384                spec.path.as_deref().map(Arc::from),
385            ),
386        };
387        for connection in &mut ns.connections {
388            connection.port = port;
389        }
390        config.add_name_server(ns);
391    }
392    config
393}
394
395/// DNS resolver for querying various record types.
396///
397/// Uses Google DNS (8.8.8.8) by default, but supports custom nameservers
398/// over plain UDP, DNS over TLS (`tls://`), and DNS over HTTPS (`https://`)
399/// — see [`NameserverSpec`] for the accepted forms.
400/// The default resolver is cached and reused across queries to avoid
401/// repeated initialization overhead.
402#[derive(Clone)]
403pub struct DnsResolver {
404    timeout: Duration,
405    /// Cached default resolver (Google DNS). Reused across all queries
406    /// that don't specify a custom nameserver.
407    default_resolver: TokioResolver,
408    /// [`query`](Self::query)'s direct transport, built once with the same
409    /// timeout (its rustls client config is not free to build).
410    transport: Transport,
411    /// Port override for custom-nameserver queries. Always `None` in
412    /// production (the port comes from the parsed [`NameserverSpec`]);
413    /// settable only through the `#[cfg(test)]` seam so mock-server tests
414    /// can bind an ephemeral local port.
415    port_override: Option<u16>,
416    /// When true, skips the SSRF/reserved-IP validation on custom
417    /// nameservers so tests can point the resolver at a 127.0.0.1 fixture.
418    /// Not settable outside `#[cfg(test)]` builds — production paths always
419    /// validate.
420    allow_private_hosts: bool,
421    /// Resolvers built for custom nameservers, reused across lookups (see
422    /// [`CustomResolvers`]).
423    custom: Arc<CustomResolvers>,
424    /// Test-only: nameserver spec used when a caller passes `None`, so code
425    /// that always queries the default upstream (posture, CAA) can be pointed
426    /// at the loopback fixture. Absent from release builds.
427    #[cfg(test)]
428    test_default_nameserver: Option<String>,
429}
430
431impl std::fmt::Debug for DnsResolver {
432    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433        f.debug_struct("DnsResolver")
434            .field("timeout", &self.timeout)
435            .finish()
436    }
437}
438
439impl Default for DnsResolver {
440    fn default() -> Self {
441        Self::new()
442    }
443}
444
445impl DnsResolver {
446    /// Creates a new DNS resolver with default settings.
447    pub fn new() -> Self {
448        Self::with_timeout_only(DEFAULT_DNS_TIMEOUT)
449    }
450
451    /// A resolver with `timeout` and every other setting at its default:
452    /// the one place the cached resolver and transport are built, once each.
453    fn with_timeout_only(timeout: Duration) -> Self {
454        Self {
455            timeout,
456            default_resolver: build_default_resolver(timeout),
457            transport: Transport::new(timeout),
458            port_override: None,
459            allow_private_hosts: false,
460            custom: Arc::default(),
461            #[cfg(test)]
462            test_default_nameserver: None,
463        }
464    }
465
466    /// Builds a resolver honoring `~/.seer/config.toml` settings.
467    ///
468    /// Reads `timeouts.dns_secs` (already clamped to 1–60s by
469    /// [`crate::config::SeerConfig::load`]) — the same resolver as
470    /// `DnsResolver::new().with_timeout(config.dns_timeout())`, built once.
471    ///
472    /// The `nameserver` config key is deliberately NOT applied here: the
473    /// resolver takes the nameserver per-query (see [`DnsResolver::resolve`]),
474    /// so callers thread `config.nameserver` at the call site where it can be
475    /// overridden per-invocation.
476    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
477        Self::with_timeout_only(config.dns_timeout())
478    }
479
480    /// Test-only: the per-query timeout, for asserting config plumbing.
481    #[cfg(test)]
482    pub(crate) fn timeout(&self) -> Duration {
483        self.timeout
484    }
485
486    /// Test-only: allow custom nameservers on loopback/private hosts (mock servers).
487    #[cfg(test)]
488    pub(crate) fn allowing_private_hosts(mut self) -> Self {
489        self.allow_private_hosts = true;
490        self.custom = Arc::default();
491        self
492    }
493
494    /// Test-only: query custom nameservers on a non-standard port (mock
495    /// servers bind ephemeral ports).
496    #[cfg(test)]
497    pub(crate) fn with_port(mut self, port: u16) -> Self {
498        self.port_override = Some(port);
499        self.custom = Arc::default();
500        self
501    }
502
503    /// Test-only: send queries that name no nameserver to `nameserver`
504    /// instead of the cached default (Google) resolver.
505    #[cfg(test)]
506    pub(crate) fn with_default_nameserver(mut self, nameserver: &str) -> Self {
507        self.test_default_nameserver = Some(nameserver.to_string());
508        self
509    }
510
511    /// Test-only: point the cached default resolver — which also resolves a
512    /// nameserver given by hostname — at a loopback fixture on `port`. Call
513    /// after [`with_timeout`](Self::with_timeout), which rebuilds it.
514    #[cfg(test)]
515    pub(crate) fn with_default_upstream_port(mut self, port: u16) -> Self {
516        self.default_resolver = build_resolver(
517            single_server_config(std::net::Ipv4Addr::LOCALHOST.into(), port, false),
518            self.timeout,
519        )
520        .expect("a plain UDP resolver builds");
521        self.custom = Arc::default();
522        self
523    }
524
525    /// The nameserver a query actually uses: the caller's choice, or (in test
526    /// builds only) the fixture default installed by
527    /// [`with_default_nameserver`](Self::with_default_nameserver).
528    #[cfg(test)]
529    fn effective_nameserver<'a>(&'a self, nameserver: Option<&'a str>) -> Option<&'a str> {
530        nameserver.or(self.test_default_nameserver.as_deref())
531    }
532
533    #[cfg(not(test))]
534    fn effective_nameserver<'a>(&self, nameserver: Option<&'a str>) -> Option<&'a str> {
535        nameserver
536    }
537
538    /// Sets the timeout for DNS queries.
539    ///
540    /// The default is 5 seconds, which is sufficient for most DNS queries.
541    pub fn with_timeout(mut self, timeout: Duration) -> Self {
542        self.timeout = timeout;
543        self.default_resolver = build_default_resolver(timeout);
544        self.transport = Transport::new(timeout);
545        self.custom = Arc::default();
546        self
547    }
548
549    /// Test-only: the resolver `resolve` builds for `nameserver`, for
550    /// asserting its spec parsing and SSRF vetting.
551    #[cfg(test)]
552    async fn create_custom_resolver(&self, nameserver: &str) -> Result<TokioResolver> {
553        Ok(self.custom_target(nameserver).await?.resolver.into_owned())
554    }
555
556    /// The resolver for `nameserver`: the memo's (see [`CustomResolvers`]),
557    /// built by [`custom_target`](Self::custom_target) on first use (see
558    /// [`CustomSlot`]).
559    async fn memoized_custom_target(&self, nameserver: &str) -> Result<ResolveTarget<'static>> {
560        // Held only to read or fill the slot, never across an await. A panic
561        // there leaves it empty or filled, so a poisoned lock is safe to use.
562        let slot = self.custom.slot(nameserver);
563        let installed = slot.lock().unwrap_or_else(|e| e.into_inner()).clone();
564        let (resolver, config) = match installed {
565            Some(built) => built,
566            None => {
567                let target = self.custom_target(nameserver).await?;
568                slot.lock()
569                    .unwrap_or_else(|e| e.into_inner())
570                    .get_or_insert_with(|| (target.resolver.into_owned(), target.config))
571                    .clone()
572            }
573        };
574        Ok(ResolveTarget {
575            resolver: Cow::Owned(resolver),
576            config,
577        })
578    }
579
580    /// A resolver built for `nameserver` (parsed, resolved and SSRF-vetted by
581    /// `custom_upstream_config`), with the config it was built from.
582    async fn custom_target(&self, nameserver: &str) -> Result<ResolveTarget<'static>> {
583        let config = self.custom_upstream_config(nameserver).await?;
584        Ok(ResolveTarget {
585            resolver: Cow::Owned(build_resolver(config.clone(), self.timeout)?),
586            config,
587        })
588    }
589
590    /// The cached default resolver, with the config it was built from.
591    fn default_target(&self) -> ResolveTarget<'_> {
592        ResolveTarget {
593            resolver: Cow::Borrowed(&self.default_resolver),
594            config: default_upstream_config(),
595        }
596    }
597
598    /// The upstream config for a caller's nameserver spec: parsed, its host
599    /// resolved when it is a name (under one per-query timeout), every
600    /// address SSRF-vetted, then one server per address (see
601    /// [`build_upstream_config`]). The one path to a custom
602    /// nameserver, shared by the resolver ([`resolve`](Self::resolve)) and
603    /// the direct queries of [`query`](Self::query), so the vetting cannot
604    /// differ between them.
605    async fn custom_upstream_config(&self, nameserver: &str) -> Result<ResolverConfig> {
606        // Parse the spec first: bare IP/host (UDP), tls:// (DoT), https://
607        // (DoH). Every surface (CLI, REPL, config.toml, py/REST/MCP) funnels
608        // its opaque nameserver string through here, so this one parse gives
609        // all of them every transport.
610        let spec = NameserverSpec::parse(nameserver)?;
611
612        // Accept either a literal IP or a hostname. For hostnames, resolve
613        // via the default (Google DNS) hickory resolver so we do not depend
614        // on the OS resolver — that is the same fallback principle as the
615        // SSL probe fix: when the local system resolver is broken (split
616        // DNS, broken router, container netns), hickory still reaches the
617        // public name servers and the user-supplied authoritative server
618        // is still usable. DoT/DoH hostnames bootstrap-resolve through this
619        // exact same path (the TLS handshake still verifies the hostname).
620        let ips: Vec<IpAddr> = if let Ok(ip) = spec.host.parse::<IpAddr>() {
621            vec![ip]
622        } else {
623            // The resolver re-sends a lookup that timed out (`attempts`), so
624            // only this deadline holds it to one per-query timeout.
625            let lookup = self.default_resolver.lookup_ip(spec.host.as_str());
626            let response = tokio::time::timeout(self.timeout, lookup)
627                .await
628                .unwrap_or(Err(NetError::Timeout))
629                .map_err(|e| {
630                    SeerError::DnsError(format!(
631                        "failed to resolve nameserver hostname {}: {}",
632                        spec.host, e
633                    ))
634                })?;
635            let resolved: Vec<IpAddr> = response.iter().collect();
636            if resolved.is_empty() {
637                return Err(SeerError::DnsError(format!(
638                    "nameserver {} did not resolve to any addresses",
639                    spec.host
640                )));
641            }
642            resolved
643        };
644
645        self.vet_nameserver_ips(nameserver, &ips)?;
646        Ok(build_upstream_config(&spec, &ips, self.port_override))
647    }
648
649    /// SSRF protection: rejects private/reserved IPs — whether supplied
650    /// literally or returned by name resolution, and identically for UDP,
651    /// tls:// and https:// specs. Without this, a hostname under attacker
652    /// control could point at internal infra. `allow_private_hosts` is only
653    /// settable via the `#[cfg(test)]` seam; production builds always
654    /// validate.
655    fn vet_nameserver_ips(&self, nameserver: &str, ips: &[IpAddr]) -> Result<()> {
656        if self.allow_private_hosts {
657            return Ok(());
658        }
659        for ip in ips {
660            if let Some(reason) = crate::validation::describe_reserved_ip(ip) {
661                return Err(SeerError::DnsError(format!(
662                    "nameserver {} blocked: {}",
663                    nameserver, reason
664                )));
665            }
666        }
667        Ok(())
668    }
669
670    /// The checks on a nameserver spec that send nothing: it parses, and an
671    /// IP-literal host passes [`vet_nameserver_ips`](Self::vet_nameserver_ips).
672    /// For a query hickory answers itself ([`answered_locally`]), where the
673    /// server is never asked, so resolving its hostname would be network
674    /// traffic for nothing.
675    fn vet_nameserver_offline(&self, nameserver: &str) -> Result<()> {
676        let spec = NameserverSpec::parse(nameserver)?;
677        match spec.host.parse::<IpAddr>() {
678            Ok(ip) => self.vet_nameserver_ips(nameserver, &[ip]),
679            Err(_) => Ok(()),
680        }
681    }
682
683    /// The resolver a [`resolve`](Self::resolve) call runs on: the memoized
684    /// one for `nameserver` (one built for this call alone when `fresh`), or
685    /// the cached default.
686    async fn upstream(&self, nameserver: Option<&str>, fresh: bool) -> Result<ResolveTarget<'_>> {
687        Ok(match self.effective_nameserver(nameserver) {
688            Some(ns) if fresh => self.custom_target(ns).await?,
689            Some(ns) => self.memoized_custom_target(ns).await?,
690            None => self.default_target(),
691        })
692    }
693
694    /// Where [`query`](Self::query) sends a query for `name`: hickory's
695    /// resolver for a special-use name it answers itself (the nameserver is
696    /// only vetted offline — it is never asked), else straight to the servers
697    /// of `nameserver` (parsed, resolved and SSRF-vetted by
698    /// `custom_upstream_config`) or of the default upstream. The query's
699    /// deadline starts here, before a nameserver hostname is resolved, so
700    /// that lookup spends the same budget as the exchanges after it.
701    async fn query_upstream(&self, name: &str, nameserver: Option<&str>) -> Result<Upstream<'_>> {
702        if answered_locally(name) {
703            if let Some(ns) = self.effective_nameserver(nameserver) {
704                self.vet_nameserver_offline(ns)?;
705            }
706            return Ok(Upstream::Local(&self.default_resolver));
707        }
708        let deadline = self.transport.deadline();
709        let config = match self.effective_nameserver(nameserver) {
710            Some(ns) => self.custom_upstream_config(ns).await?,
711            None => default_upstream_config(),
712        };
713        Ok(Upstream::Direct {
714            config,
715            transport: &self.transport,
716            deadline,
717        })
718    }
719
720    /// Queries one name the way `dig` does and reports the whole response:
721    /// status, header flags, the CNAME chain and answers under their real
722    /// owner names, the AUTHORITY section, and a wildcard probe.
723    ///
724    /// Takes the same input as [`resolve`](Self::resolve) — normalization,
725    /// the dig-style `_service._proto.name` SRV form, a raw IP literal for
726    /// PTR, and a custom nameserver spec (SSRF-vetted) or `None` for the
727    /// default upstream — but where `resolve` folds every negative answer
728    /// into an empty list, `query` reports it: NXDOMAIN, NODATA, SERVFAIL
729    /// and REFUSED are `Ok` results with that [`DnsQueryResult::status`].
730    /// An `Err` means there is no response to report: invalid input (a
731    /// malformed name, a bare-domain SRV query), a nameserver that was
732    /// refused (a private or reserved address) or did not resolve, or a
733    /// transport failure (timeout, no connection) at every server.
734    ///
735    /// Like dig, it sends one query (with RD set) and reports the response
736    /// the server sent — header, ANSWER and AUTHORITY sections as they came
737    /// — rather than a resolver lookup, which would chase a CNAME itself and
738    /// fold a negative answer into an error: a chain that ends at a name that
739    /// does not exist is reported with the chain and NXDOMAIN, as the
740    /// recursive server returns it. The servers are the nameserver's (or the
741    /// default upstream's), asked in order, IPv4 first; UDP, repeated over
742    /// TCP when the reply is truncated; and a server that does not respond
743    /// passes the query to the next, all under two DNS timeouts from the
744    /// call — resolving a nameserver given by hostname included, which is
745    /// itself held to one.
746    ///
747    /// `ANY` fans out concurrently to A, AAAA, CNAME, MX, NS, TXT, SOA, CAA,
748    /// HTTPS, DS and DNSKEY and merges the answers (see
749    /// [`DnsQueryResult::answers`]); a sub-query that failed is listed in
750    /// [`DnsQueryResult::failed_types`], and the query errors only when
751    /// every sub-query failed.
752    ///
753    /// For a name strictly below its registrable domain (`www.example.com`,
754    /// not `example.com`), a random sibling (`seer-probe-….example.com`) is
755    /// queried for the same type concurrently with the main query, and its
756    /// outcome attached as [`DnsQueryResult::wildcard`] when the main answer
757    /// has records. The probe never fails the query, a positive answer waits
758    /// for it at most a fifth of the DNS timeout, and
759    /// [`DnsQueryResult::query_time_ms`] times the main query alone.
760    ///
761    /// A special-use name (RFC 6761: `localhost`, `127.in-addr.arpa`,
762    /// `invalid`, `onion`, …) is never sent to a server — RFC 7686 forbids
763    /// leaking `.onion` names into the DNS — but answered by hickory's
764    /// resolver itself; the result says so
765    /// ([`DnsQueryResult::answered_locally`]) and runs no probe. A
766    /// nameserver given with it is parsed and, when an IP literal, vetted,
767    /// but a hostname is not resolved: nothing is sent to it.
768    #[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
769    pub async fn query(
770        &self,
771        domain: &str,
772        record_type: RecordType,
773        nameserver: Option<&str>,
774    ) -> Result<DnsQueryResult> {
775        // Input first: a bad name must not cost a nameserver-hostname lookup.
776        let domain = prepare_query(domain, record_type)?;
777        let name = wire_query_name(&domain, record_type)?;
778        let upstream = self.query_upstream(&name, nameserver).await?;
779
780        debug!(
781            nameserver = nameserver.unwrap_or(DEFAULT_UPSTREAM),
782            "Querying DNS"
783        );
784
785        let (exchange, query_time, wildcard) = if record_type == RecordType::ANY {
786            let started = Instant::now();
787            let exchange = merge_any(exchange_any(&upstream, &name).await)?;
788            (exchange, started.elapsed(), None)
789        } else {
790            let grace = self.timeout / PROBE_GRACE_DIVISOR;
791            exchange_with_probe(&upstream, &name, record_type, grace).await?
792        };
793        Ok(exchange.into_result(name, record_type, nameserver, wildcard, query_time))
794    }
795
796    /// One server's view of a name, for propagation checks:
797    /// [`query`](Self::query) sent to `nameserver` alone, without the
798    /// wildcard probe, and with a transport failure reported as
799    /// [`ServerReply::Silent`] and its reason rather than as an error. For
800    /// ANY the reply is `Silent` only when no sub-query got a response; a
801    /// partial one lists the rest in [`DnsQueryResult::failed_types`], which
802    /// a comparison must read as "unknown", not as "no records". An `Err` is
803    /// invalid input or a nameserver that was refused (private or reserved)
804    /// or did not resolve.
805    pub(crate) async fn query_server(
806        &self,
807        domain: &str,
808        record_type: RecordType,
809        nameserver: &str,
810    ) -> Result<ServerReply> {
811        let domain = prepare_query(domain, record_type)?;
812        let name = wire_query_name(&domain, record_type)?;
813        if answered_locally(&name) {
814            // Never sent (RFC 7686); `query` answers it without a probe.
815            return self
816                .query(&domain, record_type, Some(nameserver))
817                .await
818                .map(ServerReply::Response);
819        }
820        let upstream = self.query_upstream(&name, Some(nameserver)).await?;
821
822        let started = Instant::now();
823        let exchange = if record_type == RecordType::ANY {
824            let replies = exchange_any(&upstream, &name).await;
825            // Silent only when no sub-query got through; a partial answer
826            // carries its failed types (`DnsQueryResult::failed_types`).
827            if replies.iter().all(|(_, reply)| matches!(reply, Ok(Err(_)))) {
828                if let Some((_, Ok(Err(why)))) = replies.first() {
829                    return Ok(ServerReply::Silent(why.reason()));
830                }
831            }
832            merge_any(replies)?
833        } else {
834            match upstream.try_exchange(&name, record_type).await? {
835                Ok(exchange) => exchange,
836                Err(why) => return Ok(ServerReply::Silent(why.reason())),
837            }
838        };
839        Ok(ServerReply::Response(exchange.into_result(
840            name,
841            record_type,
842            Some(nameserver),
843            None,
844            started.elapsed(),
845        )))
846    }
847
848    /// Resolves DNS records for a domain: the records of `record_type` only,
849    /// each named by the queried name, with NXDOMAIN and NODATA folded into
850    /// an empty list. For the response itself — status, CNAME chain, the SOA
851    /// of a negative answer — use [`query`](Self::query).
852    ///
853    /// # Arguments
854    /// * `domain` - The domain name to query
855    /// * `record_type` - The type of DNS record to look up (A, AAAA, MX, etc.)
856    /// * `nameserver` - Optional custom nameserver spec; uses Google DNS if
857    ///   None. Accepts a bare IP/hostname with optional port (UDP),
858    ///   `tls://host[:port]` (DNS over TLS), or `https://host[:port][/path]`
859    ///   (DNS over HTTPS) — see [`NameserverSpec`]
860    ///
861    /// A custom nameserver's resolver is reused for up to a minute, so a
862    /// repeated name may be answered from its cache, as the default
863    /// upstream's always may. Callers that must observe the server afresh
864    /// each time (`follow`, `doctor`, propagation) use the crate's
865    /// `resolve_uncached`.
866    #[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
867    pub async fn resolve(
868        &self,
869        domain: &str,
870        record_type: RecordType,
871        nameserver: Option<&str>,
872    ) -> Result<Vec<DnsRecord>> {
873        self.resolve_on(domain, record_type, nameserver, false)
874            .await
875    }
876
877    /// [`resolve`](Self::resolve) through a resolver built for this call
878    /// alone when `nameserver` is given, so nothing is served from an earlier
879    /// answer: for callers that watch a server over time (`follow`,
880    /// propagation) or probe its health (`doctor`), where a cached answer
881    /// would hide a change or a dead server.
882    pub(crate) async fn resolve_uncached(
883        &self,
884        domain: &str,
885        record_type: RecordType,
886        nameserver: Option<&str>,
887    ) -> Result<Vec<DnsRecord>> {
888        self.resolve_on(domain, record_type, nameserver, true).await
889    }
890
891    /// [`resolve`](Self::resolve), with a fresh custom-nameserver resolver
892    /// when `fresh`.
893    async fn resolve_on(
894        &self,
895        domain: &str,
896        record_type: RecordType,
897        nameserver: Option<&str>,
898        fresh: bool,
899    ) -> Result<Vec<DnsRecord>> {
900        // Input first: a bad name must not cost a nameserver-hostname lookup.
901        let domain = prepare_query(domain, record_type)?;
902        let target = self.upstream(nameserver, fresh).await?;
903
904        debug!(
905            nameserver = nameserver.unwrap_or(DEFAULT_UPSTREAM),
906            "Resolving DNS"
907        );
908
909        match record_type {
910            RecordType::SRV => match parse_srv_query(&domain) {
911                // dig-style `_service._proto.name` queries resolve directly.
912                Some((service, protocol, name)) => {
913                    self.resolve_srv_core(&target, &service, &protocol, &name)
914                        .await
915                }
916                // A bare domain isn't a valid SRV query — surface a usage hint
917                // as an input error (permanent), not a transient DNS failure.
918                None => Err(srv_format_error()),
919            },
920            RecordType::ANY => self.resolve_any(&target, &domain).await,
921            single => self.resolve_type(&target, &domain, single).await,
922        }
923    }
924
925    /// Core SRV resolution against an already-built resolver, behind the
926    /// `dig`-style `_service._proto.name` path in [`resolve`](Self::resolve).
927    /// Validates the service/protocol labels (DNS query-injection guard) then
928    /// queries `_service._proto.domain`. Label-validation failures are
929    /// [`SeerError::InvalidInput`] — they are caller mistakes, not transient
930    /// DNS failures, so they must not be advertised as retryable.
931    async fn resolve_srv_core(
932        &self,
933        target: &ResolveTarget<'_>,
934        service: &str,
935        protocol: &str,
936        domain: &str,
937    ) -> Result<Vec<DnsRecord>> {
938        let query_name = srv_query_name(service, protocol, domain)?;
939        self.resolve_records(target, &query_name, RecordType::SRV)
940            .await
941    }
942
943    /// Single-type dispatch shared by [`resolve`](Self::resolve) and
944    /// [`resolve_any`](Self::resolve_any) — the one place a seer [`RecordType`] is routed to a
945    /// lookup, so the two entry points cannot diverge.
946    ///
947    /// `SRV` and `ANY` are composite queries owned by `resolve` (label
948    /// validation / fan-out); requesting them here yields the same
949    /// "unsupported record type" error from either entry point.
950    async fn resolve_type(
951        &self,
952        target: &ResolveTarget<'_>,
953        domain: &str,
954        record_type: RecordType,
955    ) -> Result<Vec<DnsRecord>> {
956        match record_type {
957            RecordType::SRV | RecordType::ANY => Err(unsupported_record_type(record_type)),
958            // PTR accepts a raw IP literal, which is queried as its
959            // reverse-DNS name (and reported under that name).
960            RecordType::PTR => {
961                self.resolve_records(target, &ptr_query_name(domain), RecordType::PTR)
962                    .await
963            }
964            single => self.resolve_records(target, domain, single).await,
965        }
966    }
967
968    /// Generic single-type lookup: queries the wire type for `record_type`
969    /// and maps each matching answer through [`convert_rdata`]. Answers of
970    /// other types (e.g. a CNAME returned alongside A records) are skipped.
971    /// NXDOMAIN/NODATA fold to an empty vec: the absence of a type is a
972    /// valid answer, not a failure.
973    ///
974    /// MX is the one type with a meaningful intra-response order: answers
975    /// are sorted by preference so the highest-priority exchange is first.
976    async fn resolve_records(
977        &self,
978        target: &ResolveTarget<'_>,
979        domain: &str,
980        record_type: RecordType,
981    ) -> Result<Vec<DnsRecord>> {
982        let Some(wire_type) = wire_type(record_type) else {
983            return Err(unsupported_record_type(record_type));
984        };
985
986        let response = match lookup_with_tcp_fallback(
987            &target.resolver,
988            &target.config,
989            self.timeout,
990            domain,
991            wire_type,
992        )
993        .await
994        {
995            Ok(response) => response,
996            Err(e) if e.is_no_records_found() => return Ok(vec![]),
997            Err(e) => {
998                return Err(SeerError::DnsError(format!(
999                    "{record_type} lookup failed: {e}"
1000                )))
1001            }
1002        };
1003
1004        let mut records: Vec<DnsRecord> = response
1005            .answers()
1006            .iter()
1007            .filter_map(|record| {
1008                convert_rdata(record_type, &record.data, NameSpelling::Display).map(|data| {
1009                    DnsRecord {
1010                        name: domain.to_string(),
1011                        record_type,
1012                        ttl: record.ttl,
1013                        data,
1014                    }
1015                })
1016            })
1017            .collect();
1018
1019        if record_type == RecordType::MX {
1020            records.sort_by_key(|r| match &r.data {
1021                RecordData::MX { preference, .. } => *preference,
1022                _ => 0,
1023            });
1024        }
1025
1026        Ok(records)
1027    }
1028
1029    async fn resolve_any(
1030        &self,
1031        target: &ResolveTarget<'_>,
1032        domain: &str,
1033    ) -> Result<Vec<DnsRecord>> {
1034        // Query the ANY types concurrently — previously these ran serially,
1035        // making `ANY` ~7x slower than a single query (#61). `join_all`
1036        // preserves input order, so the merged record list keeps the
1037        // `ANY_TYPES` ordering.
1038        let results = future::join_all(ANY_TYPES.into_iter().map(|record_type| async move {
1039            (
1040                record_type,
1041                self.resolve_type(target, domain, record_type).await,
1042            )
1043        }))
1044        .await;
1045
1046        // An empty answer still counts as success; only when every type
1047        // errored is that error surfaced. A record list has nowhere to carry
1048        // the failed types — `query()` reports them.
1049        let (record_sets, _failed) = fold_any(results)?;
1050        Ok(dedupe_records(record_sets.into_iter().flatten()))
1051    }
1052}
1053
1054/// Where [`DnsResolver::query`] sends its queries.
1055enum Upstream<'a> {
1056    /// A special-use name hickory's resolver answers itself
1057    /// ([`answered_locally`]): the resolver, which sends it nowhere.
1058    Local(&'a TokioResolver),
1059    /// Any other name: straight to these (vetted) servers, one query per
1060    /// type, the response reported as the server sent it — every query
1061    /// (main, wildcard probe, ANY fan-out) before the one `deadline`.
1062    Direct {
1063        config: ResolverConfig,
1064        transport: &'a Transport,
1065        deadline: tokio::time::Instant,
1066    },
1067}
1068
1069impl Upstream<'_> {
1070    /// Runs one query for `name`/`record_type` and maps its outcome to an
1071    /// [`Exchange`], with the answers in report order.
1072    async fn exchange(&self, name: &str, record_type: RecordType) -> Result<Exchange> {
1073        self.try_exchange(name, record_type)
1074            .await?
1075            .map_err(|why| SeerError::DnsError(format!("{record_type} lookup failed: {why}")))
1076    }
1077
1078    /// [`exchange`](Self::exchange), keeping a transport failure (no server
1079    /// responded) apart from the other errors as the inner `Err`.
1080    async fn try_exchange(
1081        &self,
1082        name: &str,
1083        record_type: RecordType,
1084    ) -> Result<std::result::Result<Exchange, NoResponse>> {
1085        let wire = wire_type(record_type).ok_or_else(|| unsupported_record_type(record_type))?;
1086        let exchange = match self {
1087            Upstream::Local(resolver) => {
1088                Exchange::from_local(resolver.lookup(name, wire).await, record_type)?
1089            }
1090            Upstream::Direct {
1091                config,
1092                transport,
1093                deadline,
1094            } => {
1095                let qname = Name::from_ascii(fqdn(name))
1096                    .map_err(|e| SeerError::InvalidDomain(format!("{name}: {e}")))?;
1097                let request = transport.request(qname, wire, true);
1098                match transport
1099                    .query(config.name_servers(), &request, *deadline)
1100                    .await
1101                {
1102                    Ok(response) => Exchange::from_message(&response),
1103                    Err(why) => return Ok(Err(why)),
1104                }
1105            }
1106        };
1107        Ok(Ok(exchange.ordered(name, record_type)))
1108    }
1109}
1110
1111/// How one nameserver answered [`DnsResolver::query_server`].
1112#[derive(Debug)]
1113pub(crate) enum ServerReply {
1114    /// The server responded — any status, SERVFAIL and REFUSED included.
1115    Response(DnsQueryResult),
1116    /// It sent no response: a short reason (`timed out`, `no route from this
1117    /// host (…)`), safe to show.
1118    Silent(String),
1119}
1120
1121/// The `ANY` fan-out behind [`DnsResolver::query`] and
1122/// [`DnsResolver::query_server`]: every [`ANY_TYPES`] query concurrently,
1123/// each outcome by its type, in `ANY_TYPES` order — for [`merge_any`].
1124async fn exchange_any(upstream: &Upstream<'_>, name: &str) -> Vec<(RecordType, SubQuery)> {
1125    future::join_all(ANY_TYPES.into_iter().map(|record_type| async move {
1126        (record_type, upstream.try_exchange(name, record_type).await)
1127    }))
1128    .await
1129}
1130
1131/// One query plus, when eligible, its wildcard probe run concurrently.
1132/// Returns the main exchange, the main query's own duration, and the judged
1133/// probe.
1134///
1135/// The probe must not cost the main query anything: it never turns the
1136/// result into an error, it is dropped unawaited when the main answer
1137/// finishes first with nothing to attach it to (an error, a negative
1138/// answer), a positive answer waits at most `grace` more for it, and the
1139/// reported duration is the main query's alone.
1140async fn exchange_with_probe(
1141    upstream: &Upstream<'_>,
1142    name: &str,
1143    record_type: RecordType,
1144    grace: Duration,
1145) -> Result<(Exchange, Duration, Option<WildcardProbe>)> {
1146    let main = async {
1147        let started = Instant::now();
1148        let result = upstream.exchange(name, record_type).await;
1149        (result, started.elapsed())
1150    };
1151    let Some(probe_name) = wildcard_probe_name(name, record_type, &random_probe_label()) else {
1152        let (result, elapsed) = main.await;
1153        return Ok((result?, elapsed, None));
1154    };
1155    let probe = upstream.exchange(&probe_name, record_type);
1156
1157    let main = pin!(main);
1158    let probe = pin!(probe);
1159    let ((result, elapsed), probe) = match future::select(main, probe).await {
1160        Either::Left(((result, elapsed), probe)) => {
1161            let attachable = matches!(&result, Ok(answer) if answer.has_records(record_type));
1162            // Regression: this awaited the probe's whole budget (two
1163            // timeouts) when its server never answered, stalling an answer
1164            // already in hand.
1165            let probe = if attachable {
1166                tokio::time::timeout(grace, probe).await.ok()
1167            } else {
1168                None
1169            };
1170            ((result, elapsed), probe)
1171        }
1172        Either::Right((probe, main)) => (main.await, Some(probe)),
1173    };
1174    let answer = result?;
1175    let wildcard =
1176        probe.and_then(|probe| wildcard_outcome(&probe_name, record_type, &answer, probe));
1177    Ok((answer, elapsed, wildcard))
1178}
1179
1180/// Whether a domain appears to exist in the public DNS. Used as a
1181/// corroborating availability signal when registry data (RDAP/WHOIS) is
1182/// inconclusive — e.g. a thin/blocked WHOIS body and an RDAP failure that is
1183/// not an authoritative 404.
1184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1185pub enum DnsPresence {
1186    /// The apex returned NS records — the domain is delegated and exists.
1187    Present,
1188    /// NXDOMAIN / empty answer — the domain has no DNS presence.
1189    Absent,
1190    /// The DNS query itself failed; presence is unknown.
1191    Unknown,
1192}
1193
1194/// Maps an apex NS lookup result to a [`DnsPresence`]. Pure so the mapping is
1195/// unit-testable without a live resolver. `resolve(.., NS, ..)` already folds
1196/// NXDOMAIN/NODATA into `Ok(vec![])` (see `dns_lookup_or_empty`), so an empty
1197/// `Ok` is the "no presence" signal and an `Err` is a genuine query failure.
1198fn classify_ns_presence(result: &Result<Vec<DnsRecord>>) -> DnsPresence {
1199    match result {
1200        Ok(records) if records.is_empty() => DnsPresence::Absent,
1201        Ok(_) => DnsPresence::Present,
1202        Err(_) => DnsPresence::Unknown,
1203    }
1204}
1205
1206impl DnsResolver {
1207    /// Probes whether a domain has any DNS presence by querying its apex NS
1208    /// records. A registered, delegated domain returns NS records; an
1209    /// unregistered domain returns NXDOMAIN (an empty record set).
1210    ///
1211    /// This is a heuristic, not proof: a registered-but-undelegated domain
1212    /// also has no NS records, so callers should treat
1213    /// [`DnsPresence::Absent`] as "likely available" (medium confidence).
1214    pub async fn presence(&self, domain: &str) -> DnsPresence {
1215        // A registration-level question: `www.example.com` means
1216        // `example.com` here (as it does for WHOIS/RDAP), so strip `www.`
1217        // explicitly — `resolve` itself now keeps it (see `prepare_query`).
1218        let Ok(apex) = normalize_domain(domain) else {
1219            return DnsPresence::Unknown;
1220        };
1221        classify_ns_presence(&self.resolve(&apex, RecordType::NS, None).await)
1222    }
1223}
1224
1225/// Prepares the query name for a DNS record lookup.
1226///
1227/// Record queries are about one exact DNS name, so this normalizes with
1228/// [`normalize_query_name`], which — unlike [`normalize_domain`] — keeps a
1229/// leading `www.`: `www` routinely carries its own records (typically a
1230/// CNAME), and stripping it silently answered `dig www.example.com CNAME` for
1231/// the apex. It also accepts a leading wildcard label (`*.example.com`), so
1232/// the wildcard's own records can be queried like any other name.
1233///
1234/// PTR queries may be given a raw IP literal. IPv6 literals in particular must
1235/// NOT pass through the normalizer: its trailing-`:port` strip heuristic
1236/// truncates the final hextet (e.g. `::1111` → dropped) and the remaining `:`
1237/// separators then fail character validation, so IPv6 reverse lookups errored
1238/// out with "Invalid domain name" before ever reaching `resolve_ptr`. For PTR
1239/// queries we therefore detect an IP literal up front and pass it through in
1240/// canonical form; everything else (domains, and PTR queries given a
1241/// reverse-DNS name such as `1.1.1.1.in-addr.arpa`) is normalized as usual.
1242///
1243/// Shared (crate-internal) with compare / propagation / follow so every entry
1244/// point that stores or echoes the queried name agrees with what `resolve`
1245/// actually queries — they previously re-normalized with `normalize_domain`,
1246/// losing `www.` and mangling IPv6 PTR literals.
1247pub(crate) fn prepare_query(domain: &str, record_type: RecordType) -> Result<String> {
1248    prepare_query_with(domain, record_type, normalize_query_name)
1249}
1250
1251/// [`prepare_query`] with the normalizer injected, so the IP-literal PTR path
1252/// is testable against a rejecting normalizer (the real one reads the
1253/// process-global `SEER_DOMAIN_ALLOWLIST`, which tests cannot set).
1254fn prepare_query_with(
1255    domain: &str,
1256    record_type: RecordType,
1257    normalize: impl Fn(&str) -> Result<String>,
1258) -> Result<String> {
1259    if record_type == RecordType::PTR {
1260        if let Ok(ip) = IpAddr::from_str(domain.trim()) {
1261            // `SEER_DOMAIN_ALLOWLIST` is enforced inside the normalizer, which
1262            // an IP literal skips. Run the name that will actually be queried
1263            // (the reverse-DNS name) through it, so a PTR query for an IP
1264            // obeys the allowlist exactly like the equivalent
1265            // `x.x.x.x.in-addr.arpa` query does.
1266            normalize(&reverse_dns_name(&ip))?;
1267            return Ok(ip.to_string());
1268        }
1269    }
1270    normalize(domain)
1271}
1272
1273/// Parses a `dig`-style SRV query name of the form `_service._proto.name` into
1274/// its `(service, protocol, name)` parts, with the leading underscores
1275/// stripped. Returns `None` when the input is not in that shape — e.g. a bare
1276/// domain with no service/proto labels — so callers can surface a usage hint.
1277pub(crate) fn parse_srv_query(name: &str) -> Option<(String, String, String)> {
1278    let mut parts = name.splitn(3, '.');
1279    let service = parts.next()?.strip_prefix('_')?;
1280    let protocol = parts.next()?.strip_prefix('_')?;
1281    let rest = parts.next()?;
1282    if service.is_empty() || protocol.is_empty() || rest.is_empty() {
1283        return None;
1284    }
1285    Some((service.to_string(), protocol.to_string(), rest.to_string()))
1286}
1287
1288/// Builds the validated `_service._proto.domain` SRV query name. The labels
1289/// are checked first (a DNS query-injection guard): a bad one is a caller
1290/// mistake, so it is [`SeerError::InvalidInput`], not a retryable DNS error.
1291fn srv_query_name(service: &str, protocol: &str, domain: &str) -> Result<String> {
1292    if !is_valid_srv_label(service) {
1293        return Err(SeerError::InvalidInput(format!(
1294            "invalid SRV service name: {}",
1295            service
1296        )));
1297    }
1298    if !is_valid_srv_label(protocol) {
1299        return Err(SeerError::InvalidInput(format!(
1300            "invalid SRV protocol name: {}",
1301            protocol
1302        )));
1303    }
1304    Ok(format!("_{}._{}.{}", service, protocol, domain))
1305}
1306
1307/// The name a PTR query asks for: the reverse-DNS name of an IP literal, or
1308/// the (already reverse) name as given.
1309fn ptr_query_name(domain: &str) -> String {
1310    match IpAddr::from_str(domain) {
1311        Ok(ip) => reverse_dns_name(&ip),
1312        Err(_) => domain.to_string(),
1313    }
1314}
1315
1316/// The name that goes on the wire for a prepared query (the output of
1317/// [`prepare_query`]), with `resolve`'s rules: SRV must be a valid
1318/// `_service._proto.name`, and PTR turns an IP literal into its reverse name.
1319/// Shared with the trace walker (`dns::trace`), so a trace asks for — and
1320/// reports — the name `query` would.
1321pub(crate) fn wire_query_name(domain: &str, record_type: RecordType) -> Result<String> {
1322    match record_type {
1323        RecordType::SRV => {
1324            let (service, protocol, name) = parse_srv_query(domain).ok_or_else(srv_format_error)?;
1325            srv_query_name(&service, &protocol, &name)
1326        }
1327        RecordType::PTR => Ok(ptr_query_name(domain)),
1328        _ => Ok(domain.to_string()),
1329    }
1330}
1331
1332/// The canonical "bad SRV query name" error, shared by the single-query resolver
1333/// path and the propagation checker so both reject a bare-domain SRV query with
1334/// the identical permanent `InvalidInput` message.
1335pub(crate) fn srv_format_error() -> SeerError {
1336    SeerError::InvalidInput(
1337        "SRV records require service name format: _service._proto.name".to_string(),
1338    )
1339}
1340
1341fn reverse_dns_name(ip: &IpAddr) -> String {
1342    match ip {
1343        IpAddr::V4(addr) => {
1344            let octets = addr.octets();
1345            format!(
1346                "{}.{}.{}.{}.in-addr.arpa",
1347                octets[3], octets[2], octets[1], octets[0]
1348            )
1349        }
1350        IpAddr::V6(addr) => {
1351            let segments = addr.segments();
1352            // 32 hex nibbles + 31 dots + ".ip6.arpa" (9) = 72 chars
1353            let mut result = String::with_capacity(72);
1354            let mut first = true;
1355            for segment in segments.iter().rev() {
1356                for shift in [0, 4, 8, 12] {
1357                    if !first {
1358                        result.push('.');
1359                    }
1360                    first = false;
1361                    let nibble = (segment >> shift) & 0xF;
1362                    result
1363                        .push(char::from_digit(nibble as u32, 16).expect("nibble is always 0-15"));
1364                }
1365            }
1366            result.push_str(".ip6.arpa");
1367            result
1368        }
1369    }
1370}
1371
1372fn parse_caa(caa: &CAA) -> (u8, String, String) {
1373    // hickory 0.26: CAA fields are public. `flags()` reassembles the full
1374    // wire flags byte — the issuer-critical bit plus the reserved bits hickory
1375    // keeps in `reserved_flags` — so the reported value matches what the zone
1376    // publishes (rebuilding it from `issuer_critical` alone dropped the
1377    // reserved bits). `value` is a `Vec<u8>` because RFC 8659 permits binary
1378    // values for unknown property types. For seer's reporting purposes the
1379    // common tags (issue/issuewild/iodef) are always UTF-8, so a lossy
1380    // conversion preserves prior behavior without panicking on the rare
1381    // binary case.
1382    let flags = caa.flags();
1383    let tag = caa.tag.clone();
1384    let value = String::from_utf8_lossy(&caa.value).to_string();
1385    (flags, tag, value)
1386}
1387
1388/// Splits HTTPS/SVCB RDATA (RFC 9460) into priority, target name and
1389/// SvcParams in presentation form.
1390///
1391/// The presentation is built here rather than taken from hickory's
1392/// `Display`, which leaves a trailing comma on every list (`h3,h2,`) and
1393/// names unregistered keys `unknown<N>` instead of RFC 9460's `key<N>`.
1394fn parse_svcb(svcb: &SVCB, names: NameSpelling) -> (u16, String, Vec<SvcParam>) {
1395    let params = svcb
1396        .svc_params
1397        .iter()
1398        .map(|(key, value)| SvcParam {
1399            key: svc_param_key_name(*key),
1400            value: svc_param_value(value),
1401        })
1402        .collect();
1403    (svcb.svc_priority, names.spell(&svcb.target_name), params)
1404}
1405
1406/// The RFC 9460 presentation name of a SvcParamKey: the registered name, or
1407/// `key<N>` (§2.1) for any other key.
1408fn svc_param_key_name(key: SvcParamKey) -> String {
1409    match key {
1410        SvcParamKey::Mandatory => "mandatory".to_string(),
1411        SvcParamKey::Alpn => "alpn".to_string(),
1412        SvcParamKey::NoDefaultAlpn => "no-default-alpn".to_string(),
1413        SvcParamKey::Port => "port".to_string(),
1414        SvcParamKey::Ipv4Hint => "ipv4hint".to_string(),
1415        SvcParamKey::EchConfigList => "ech".to_string(),
1416        SvcParamKey::Ipv6Hint => "ipv6hint".to_string(),
1417        SvcParamKey::Key(_) | SvcParamKey::Key65535 | SvcParamKey::Unknown(_) => {
1418            format!("key{}", u16::from(key))
1419        }
1420    }
1421}
1422
1423/// The presentation value of a SvcParam, unquoted (RFC 9460 §2.1, §7): lists
1424/// are comma-separated, `ech` is base64, `no-default-alpn` is empty, and an
1425/// opaque value is a character-string with `\DDD` escapes.
1426fn svc_param_value(value: &SvcParamValue) -> String {
1427    use base64::{engine::general_purpose::STANDARD, Engine};
1428    fn join<T: std::fmt::Display>(items: impl Iterator<Item = T>) -> String {
1429        items
1430            .map(|item| item.to_string())
1431            .collect::<Vec<_>>()
1432            .join(",")
1433    }
1434    match value {
1435        SvcParamValue::Mandatory(keys) => join(keys.0.iter().map(|k| svc_param_key_name(*k))),
1436        // Two levels of escaping, undone in reverse by a parser (RFC 9460
1437        // Appendix A.1): the value-list level first (`\` → `\\`, `,` → `\,`)
1438        // so a comma inside an alpn-id does not split it, then the
1439        // character-string level over the result — `f\oo,bar` becomes
1440        // `f\\\\oo\\,bar` (Appendix D.2).
1441        SvcParamValue::Alpn(alpn) => join(alpn.0.iter().map(|id| {
1442            let listed = id.replace('\\', "\\\\").replace(',', "\\,");
1443            escape_char_string(listed.as_bytes())
1444        })),
1445        SvcParamValue::NoDefaultAlpn => String::new(),
1446        SvcParamValue::Port(port) => port.to_string(),
1447        SvcParamValue::Ipv4Hint(hint) => join(hint.0.iter().map(|a| a.0)),
1448        SvcParamValue::EchConfigList(ech) => STANDARD.encode(&ech.0),
1449        SvcParamValue::Ipv6Hint(hint) => join(hint.0.iter().map(|aaaa| aaaa.0)),
1450        SvcParamValue::Unknown(opaque) => escape_char_string(&opaque.0),
1451    }
1452}
1453
1454/// Renders bytes as an unquoted DNS character-string (RFC 1035 §5.1):
1455/// printable ASCII stays, `"` and `\` are backslash-escaped, and anything
1456/// else — space and control bytes included, so the value is one token —
1457/// becomes `\DDD`.
1458fn escape_char_string(bytes: &[u8]) -> String {
1459    let mut out = String::with_capacity(bytes.len());
1460    for &b in bytes {
1461        match b {
1462            b'"' | b'\\' => {
1463                out.push('\\');
1464                out.push(char::from(b));
1465            }
1466            0x21..=0x7E => out.push(char::from(b)),
1467            _ => out.push_str(&format!("\\{b:03}")),
1468        }
1469    }
1470    out
1471}
1472
1473/// Maps a concrete seer [`RecordType`] to the hickory wire type it queries.
1474///
1475/// `ANY` is a composite lookup (a fan-out over `ANY_TYPES`) and deliberately
1476/// has no wire type. `SRV` maps to its wire type, but `resolve` routes it
1477/// through its dedicated `_service._proto.name` path (`resolve_srv_core`), so
1478/// [`DnsResolver::resolve_type`] still rejects both.
1479pub(crate) fn wire_type(record_type: RecordType) -> Option<HickoryRecordType> {
1480    Some(match record_type {
1481        RecordType::A => HickoryRecordType::A,
1482        RecordType::AAAA => HickoryRecordType::AAAA,
1483        RecordType::CNAME => HickoryRecordType::CNAME,
1484        RecordType::MX => HickoryRecordType::MX,
1485        RecordType::NS => HickoryRecordType::NS,
1486        RecordType::TXT => HickoryRecordType::TXT,
1487        RecordType::SOA => HickoryRecordType::SOA,
1488        RecordType::PTR => HickoryRecordType::PTR,
1489        RecordType::SRV => HickoryRecordType::SRV,
1490        RecordType::CAA => HickoryRecordType::CAA,
1491        RecordType::DNSKEY => HickoryRecordType::DNSKEY,
1492        RecordType::DS => HickoryRecordType::DS,
1493        // RFC 7344 child-side copies of DS/DNSKEY, published for the parent
1494        // to pick up (automated DS maintenance).
1495        RecordType::CDS => HickoryRecordType::CDS,
1496        RecordType::CDNSKEY => HickoryRecordType::CDNSKEY,
1497        // RFC 9460 service bindings.
1498        RecordType::HTTPS => HickoryRecordType::HTTPS,
1499        RecordType::SVCB => HickoryRecordType::SVCB,
1500        // TLSA queries are how DANE clients discover the certificate
1501        // association data for a TLS endpoint. The convention is
1502        // `_<port>._<proto>.<host>` (e.g. `_443._tcp.example.com`); seer
1503        // does not enforce the label shape because TLSA is also used for
1504        // other transports.
1505        RecordType::TLSA => HickoryRecordType::TLSA,
1506        RecordType::SSHFP => HickoryRecordType::SSHFP,
1507        RecordType::NAPTR => HickoryRecordType::NAPTR,
1508        RecordType::ANY => return None,
1509    })
1510}
1511
1512/// The canonical error for record types that cannot be resolved as a single
1513/// wire query, shared by every dispatch path so the message never diverges.
1514fn unsupported_record_type(record_type: RecordType) -> SeerError {
1515    SeerError::DnsError(format!("unsupported record type: {}", record_type))
1516}
1517
1518/// Uppercase hex rendering for wire-format byte fields (DS digests, TLSA
1519/// certificate data, SSHFP fingerprints), matching dig's presentation.
1520fn hex_upper(bytes: &[u8]) -> String {
1521    bytes.iter().map(|b| format!("{:02X}", b)).collect()
1522}
1523
1524/// How [`convert_rdata`] spells the domain names inside RDATA: a CNAME,
1525/// PTR, SRV, HTTPS/SVCB or NAPTR target, an MX exchange, an NS host, an SOA
1526/// mname/rname.
1527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1528pub(crate) enum NameSpelling {
1529    /// hickory's `Display`, which decodes IDN labels to Unicode: what
1530    /// [`DnsResolver::resolve`] has always returned, kept for its callers.
1531    Display,
1532    /// ASCII, an IDN label as its `xn--` A-label — the spelling of owner and
1533    /// query names (see `owner_name`), so every name in a result compares
1534    /// as written (a CNAME target equals the next hop's owner) and reads as
1535    /// dig prints it.
1536    Ascii,
1537}
1538
1539impl NameSpelling {
1540    fn spell(self, name: &Name) -> String {
1541        match self {
1542            NameSpelling::Display => name.to_string(),
1543            NameSpelling::Ascii => name.to_ascii(),
1544        }
1545    }
1546}
1547
1548/// Converts one hickory answer's RData into our [`RecordData`], if it is the
1549/// variant `record_type` asked for, spelling the names in it as `names`
1550/// says. Any other RData in the answer section (e.g. a CNAME returned
1551/// alongside A records) yields `None` and is skipped.
1552///
1553/// This is the single RData→RecordData conversion table used by every
1554/// resolution path.
1555pub(crate) fn convert_rdata(
1556    record_type: RecordType,
1557    data: &HickoryRData,
1558    names: NameSpelling,
1559) -> Option<RecordData> {
1560    use hickory_resolver::proto::dnssec::rdata::DNSSECRData;
1561
1562    match (record_type, data) {
1563        (RecordType::A, HickoryRData::A(addr)) => Some(RecordData::A {
1564            address: addr.0.to_string(),
1565        }),
1566        (RecordType::AAAA, HickoryRData::AAAA(addr)) => Some(RecordData::AAAA {
1567            address: addr.0.to_string(),
1568        }),
1569        (RecordType::CNAME, HickoryRData::CNAME(cname)) => Some(RecordData::CNAME {
1570            target: names.spell(&cname.0),
1571        }),
1572        (RecordType::MX, HickoryRData::MX(mx)) => Some(RecordData::MX {
1573            preference: mx.preference,
1574            exchange: names.spell(&mx.exchange),
1575        }),
1576        (RecordType::NS, HickoryRData::NS(ns)) => Some(RecordData::NS {
1577            nameserver: names.spell(&ns.0),
1578        }),
1579        (RecordType::TXT, HickoryRData::TXT(txt)) => Some(RecordData::txt(
1580            txt.txt_data
1581                .iter()
1582                .map(|data| String::from_utf8_lossy(data).into_owned())
1583                .collect(),
1584        )),
1585        (RecordType::SOA, HickoryRData::SOA(soa)) => Some(RecordData::SOA {
1586            mname: names.spell(&soa.mname),
1587            rname: names.spell(&soa.rname),
1588            serial: soa.serial,
1589            // hickory models refresh/retry/expire as i32, but they are
1590            // unsigned 32-bit wire intervals. A value >= 2^31 arrives as a
1591            // negative i32; `try_into()` would fail and zero it out, hiding
1592            // the real (large) value. `as u32` reinterprets the bits to the
1593            // correct unsigned value instead.
1594            refresh: soa.refresh as u32,
1595            retry: soa.retry as u32,
1596            expire: soa.expire as u32,
1597            minimum: soa.minimum,
1598        }),
1599        (RecordType::PTR, HickoryRData::PTR(ptr)) => Some(RecordData::PTR {
1600            target: names.spell(&ptr.0),
1601        }),
1602        (RecordType::SRV, HickoryRData::SRV(srv)) => Some(RecordData::SRV {
1603            priority: srv.priority,
1604            weight: srv.weight,
1605            port: srv.port,
1606            target: names.spell(&srv.target),
1607        }),
1608        (RecordType::CAA, HickoryRData::CAA(caa)) => {
1609            let (flags, tag, value) = parse_caa(caa);
1610            Some(RecordData::CAA { flags, tag, value })
1611        }
1612        (RecordType::DNSKEY, HickoryRData::DNSSEC(DNSSECRData::DNSKEY(dnskey))) => {
1613            use base64::{engine::general_purpose::STANDARD, Engine};
1614            let public_key_buf = dnskey.public_key();
1615            Some(RecordData::DNSKEY {
1616                flags: dnskey.flags(),
1617                // Protocol is always 3 for DNSSEC (RFC 4034)
1618                protocol: 3,
1619                algorithm: u8::from(public_key_buf.algorithm()),
1620                public_key: STANDARD.encode(public_key_buf.public_bytes()),
1621            })
1622        }
1623        (RecordType::DS, HickoryRData::DNSSEC(DNSSECRData::DS(ds))) => Some(RecordData::DS {
1624            key_tag: ds.key_tag(),
1625            algorithm: u8::from(ds.algorithm()),
1626            digest_type: u8::from(ds.digest_type()),
1627            digest: hex_upper(ds.digest()),
1628        }),
1629        // hickory models the RFC 8078 delete request's algorithm 0 as `None`.
1630        (RecordType::CDS, HickoryRData::DNSSEC(DNSSECRData::CDS(cds))) => Some(RecordData::CDS {
1631            key_tag: cds.key_tag(),
1632            algorithm: cds.algorithm().map_or(0, u8::from),
1633            digest_type: u8::from(cds.digest_type()),
1634            digest: hex_upper(cds.digest()),
1635        }),
1636        (RecordType::CDNSKEY, HickoryRData::DNSSEC(DNSSECRData::CDNSKEY(cdnskey))) => {
1637            use base64::{engine::general_purpose::STANDARD, Engine};
1638            // hickory keeps the key bytes private and exposes them only as a
1639            // `PublicKeyBuf`, which a delete request (algorithm 0) has none
1640            // of. The RDATA layout is fixed (RFC 7344 §3.2, identical to
1641            // DNSKEY: flags(2) protocol(1) algorithm(1) key), so read the key
1642            // from the re-encoded RDATA — exact for every record, delete
1643            // requests included.
1644            let rdata = cdnskey.to_bytes().ok()?;
1645            Some(RecordData::CDNSKEY {
1646                flags: cdnskey.flags(),
1647                // Protocol is always 3 (RFC 4034); hickory rejects any other.
1648                protocol: 3,
1649                algorithm: cdnskey.algorithm().map_or(0, u8::from),
1650                public_key: STANDARD.encode(rdata.get(4..)?),
1651            })
1652        }
1653        (RecordType::HTTPS, HickoryRData::HTTPS(https)) => {
1654            let (priority, target, params) = parse_svcb(&https.0, names);
1655            Some(RecordData::HTTPS {
1656                priority,
1657                target,
1658                params,
1659            })
1660        }
1661        (RecordType::SVCB, HickoryRData::SVCB(svcb)) => {
1662            let (priority, target, params) = parse_svcb(svcb, names);
1663            Some(RecordData::SVCB {
1664                priority,
1665                target,
1666                params,
1667            })
1668        }
1669        (RecordType::TLSA, HickoryRData::TLSA(tlsa)) => Some(RecordData::TLSA {
1670            cert_usage: u8::from(tlsa.cert_usage),
1671            selector: u8::from(tlsa.selector),
1672            matching: u8::from(tlsa.matching),
1673            cert_data: hex_upper(&tlsa.cert_data),
1674        }),
1675        (RecordType::SSHFP, HickoryRData::SSHFP(sshfp)) => Some(RecordData::SSHFP {
1676            algorithm: u8::from(sshfp.algorithm),
1677            fingerprint_type: u8::from(sshfp.fingerprint_type),
1678            fingerprint: hex_upper(&sshfp.fingerprint),
1679        }),
1680        // flags/services/regexp are DNS <character-string>s (raw bytes);
1681        // they are conventionally ASCII, so a lossy decode is a faithful,
1682        // panic-free rendering.
1683        (RecordType::NAPTR, HickoryRData::NAPTR(naptr)) => Some(RecordData::NAPTR {
1684            order: naptr.order,
1685            preference: naptr.preference,
1686            flags: String::from_utf8_lossy(&naptr.flags).into_owned(),
1687            services: String::from_utf8_lossy(&naptr.services).into_owned(),
1688            regexp: String::from_utf8_lossy(&naptr.regexp).into_owned(),
1689            replacement: names.spell(&naptr.replacement),
1690        }),
1691        _ => None,
1692    }
1693}
1694
1695/// The seer [`RecordType`] a hickory wire type maps to, if seer models it.
1696///
1697/// Derived from [`wire_type`] over [`RecordType::ALL`], so the two directions
1698/// cannot drift: a type added to `wire_type` is recognized here too.
1699pub(crate) fn from_wire_type(wire: HickoryRecordType) -> Option<RecordType> {
1700    RecordType::ALL
1701        .iter()
1702        .copied()
1703        .find(|t| wire_type(*t) == Some(wire))
1704}
1705
1706/// Converts one hickory record into a [`DnsRecord`] under its real owner
1707/// name, typed by the record's own wire type — the conversion for any path
1708/// that reports a response section as-is (a CNAME chain, an authority SOA, a
1709/// trace hop), where records of several types and owners appear together.
1710///
1711/// The owner name is ASCII (see `owner_name`) and loses its trailing root
1712/// dot, matching how query names are reported; the names in the record's
1713/// data are ASCII too ([`NameSpelling::Ascii`]), so the result uses one
1714/// spelling throughout. Returns `None` for types seer does not model.
1715pub(crate) fn to_dns_record(record: &Record) -> Option<DnsRecord> {
1716    let record_type = from_wire_type(record.record_type())?;
1717    let data = convert_rdata(record_type, &record.data, NameSpelling::Ascii)?;
1718    Some(DnsRecord {
1719        name: owner_name(&record.name),
1720        record_type,
1721        ttl: record.ttl,
1722        data,
1723    })
1724}
1725
1726/// A hickory name as seer reports an owner: in ASCII — an IDN label as its
1727/// `xn--` A-label, the spelling of the (normalized) query name it sits
1728/// beside — and without the trailing root dot, except for the root itself
1729/// (`.`). hickory's `Display` would decode A-labels to Unicode.
1730fn owner_name(name: &Name) -> String {
1731    let text = name.to_ascii();
1732    match text.strip_suffix('.') {
1733        Some(rest) if !rest.is_empty() => rest.to_string(),
1734        _ => text,
1735    }
1736}
1737
1738/// Validates SRV service/protocol labels (alphanumeric and hyphens only, no dots)
1739fn is_valid_srv_label(label: &str) -> bool {
1740    !label.is_empty()
1741        && label.len() <= 63
1742        && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
1743        && !label.starts_with('-')
1744        && !label.ends_with('-')
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749    //! Unit tests for the pure helpers and public surface of the DNS
1750    //! resolver, plus hermetic mock-server tests (see the `mock_*` tests
1751    //! below) that exercise the full `resolve()` path against the shared
1752    //! loopback UDP fixture in [`crate::dns::test_support`]. Live-network
1753    //! variants remain `#[ignore]`d here and in the sibling modules
1754    //! (`dns/dnssec.rs`, `dns/follow.rs`).
1755
1756    use super::*;
1757
1758    /// Regression: an intermittent 5-second stall on every default-resolver
1759    /// lookup, and an outright `seer doctor` FAIL, on hosts that advertise an
1760    /// IPv6 default route but have no working IPv6 transit (RA-advertised
1761    /// IPv6 with no real transit is common on consumer networks).
1762    ///
1763    /// `GOOGLE` carries two IPv4 and two IPv6 addresses. hickory queries
1764    /// `num_concurrent_reqs` (default 2) servers in parallel, and under the
1765    /// default `QueryStatistics` ordering a short-lived CLI process has no
1766    /// statistics to order by — so roughly one run in six drew both IPv6
1767    /// servers, black-holed, and burned the full per-query timeout.
1768    ///
1769    /// `UserProvidedOrder` makes the first two picks deterministically the
1770    /// IPv4 servers. IPv6 stays in the list for IPv6-only hosts, where the
1771    /// IPv4 sends fail immediately with ENETUNREACH rather than timing out.
1772    #[test]
1773    fn standard_opts_pin_server_order_so_ipv4_is_tried_first() {
1774        let mut opts = ResolverOpts::default();
1775        apply_standard_opts(&mut opts, Duration::from_secs(5));
1776        assert_eq!(
1777            opts.server_ordering_strategy,
1778            ServerOrderingStrategy::UserProvidedOrder,
1779            "QueryStatistics ordering is nondeterministic in a short-lived \
1780             process and can draw only black-holed IPv6 servers"
1781        );
1782    }
1783
1784    /// The ordering fix is only meaningful if the configured list actually
1785    /// starts with IPv4 servers, and only sufficient if at least
1786    /// `num_concurrent_reqs` of them are IPv4 — otherwise a parallel pair
1787    /// still includes a black-holeable IPv6 server.
1788    #[test]
1789    fn default_config_lists_enough_ipv4_servers_before_any_ipv6() {
1790        let config = ResolverConfig::udp_and_tcp(&GOOGLE);
1791        let ips: Vec<_> = config.name_servers().iter().map(|ns| ns.ip).collect();
1792        let concurrent = ResolverOpts::default().num_concurrent_reqs.max(1);
1793        assert!(
1794            ips.len() > concurrent,
1795            "expected more servers than the concurrency window: {ips:?}"
1796        );
1797        for (i, ip) in ips.iter().take(concurrent).enumerate() {
1798            assert!(
1799                ip.is_ipv4(),
1800                "server {i} in the concurrency window is not IPv4: {ips:?}"
1801            );
1802        }
1803    }
1804
1805    #[test]
1806    fn from_config_applies_dns_timeout() {
1807        let mut config = crate::config::SeerConfig::default();
1808        config.timeouts.dns_secs = 9;
1809        let resolver = DnsResolver::from_config(&config);
1810        assert_eq!(resolver.timeout, Duration::from_secs(9));
1811        assert_eq!(resolver.transport.timeout(), Duration::from_secs(9));
1812        let rebuilt = DnsResolver::new().with_timeout(Duration::from_secs(3));
1813        assert_eq!(rebuilt.transport.timeout(), Duration::from_secs(3));
1814    }
1815
1816    #[test]
1817    fn transport_budget_is_the_resolvers_attempts() {
1818        let mut opts = ResolverOpts::default();
1819        apply_standard_opts(&mut opts, Duration::from_secs(5));
1820        assert_eq!(
1821            opts.attempts as u32,
1822            crate::dns::transport::QUERY_BUDGET_TIMEOUTS
1823        );
1824    }
1825    use std::net::{Ipv4Addr, Ipv6Addr};
1826
1827    // --- is_valid_srv_label ------------------------------------------
1828
1829    #[test]
1830    fn srv_label_accepts_alphanumeric_and_hyphen() {
1831        assert!(is_valid_srv_label("http"));
1832        assert!(is_valid_srv_label("ldap-tls"));
1833        assert!(is_valid_srv_label("a1"));
1834        assert!(is_valid_srv_label("tcp"));
1835    }
1836
1837    #[test]
1838    fn srv_label_rejects_empty() {
1839        assert!(!is_valid_srv_label(""));
1840    }
1841
1842    #[test]
1843    fn srv_label_rejects_leading_or_trailing_hyphen() {
1844        assert!(!is_valid_srv_label("-http"));
1845        assert!(!is_valid_srv_label("http-"));
1846        assert!(!is_valid_srv_label("-"));
1847    }
1848
1849    #[test]
1850    fn srv_label_rejects_dots() {
1851        // Dots would let an attacker construct `_service._tcp.evil.com.target`
1852        // and pivot the query to a different domain.
1853        assert!(!is_valid_srv_label("http.evil"));
1854        assert!(!is_valid_srv_label("a.b"));
1855    }
1856
1857    #[test]
1858    fn srv_label_rejects_special_chars() {
1859        assert!(!is_valid_srv_label("http evil"));
1860        assert!(!is_valid_srv_label("http/evil"));
1861        assert!(!is_valid_srv_label("http\0"));
1862        assert!(!is_valid_srv_label("http\n"));
1863    }
1864
1865    #[test]
1866    fn srv_label_rejects_over_63_chars() {
1867        let too_long = "a".repeat(64);
1868        assert!(!is_valid_srv_label(&too_long));
1869        let exactly_63 = "a".repeat(63);
1870        assert!(is_valid_srv_label(&exactly_63));
1871    }
1872
1873    // --- classify_ns_presence ----------------------------------------
1874
1875    #[test]
1876    fn classify_ns_presence_absent_on_empty_ok() {
1877        // resolve(.., NS) folds NXDOMAIN/NODATA into Ok(vec![]).
1878        let r: Result<Vec<DnsRecord>> = Ok(vec![]);
1879        assert_eq!(classify_ns_presence(&r), DnsPresence::Absent);
1880    }
1881
1882    #[test]
1883    fn classify_ns_presence_present_on_records() {
1884        let rec = DnsRecord {
1885            name: "example.test.".to_string(),
1886            record_type: RecordType::NS,
1887            ttl: 3600,
1888            data: RecordData::NS {
1889                nameserver: "ns1.example.net.".to_string(),
1890            },
1891        };
1892        let r: Result<Vec<DnsRecord>> = Ok(vec![rec]);
1893        assert_eq!(classify_ns_presence(&r), DnsPresence::Present);
1894    }
1895
1896    #[test]
1897    fn classify_ns_presence_unknown_on_error() {
1898        let r: Result<Vec<DnsRecord>> = Err(SeerError::DnsError("servfail".to_string()));
1899        assert_eq!(classify_ns_presence(&r), DnsPresence::Unknown);
1900    }
1901
1902    // --- reverse_dns_name --------------------------------------------
1903
1904    #[test]
1905    fn reverse_dns_name_formats_ipv4_correctly() {
1906        let ip: IpAddr = Ipv4Addr::new(192, 0, 2, 1).into();
1907        assert_eq!(reverse_dns_name(&ip), "1.2.0.192.in-addr.arpa");
1908    }
1909
1910    #[test]
1911    fn reverse_dns_name_formats_ipv6_correctly() {
1912        // ::1 (loopback) → 32 nibbles of 0 followed by ...0.0.0.1 reversed.
1913        let ip: IpAddr = Ipv6Addr::LOCALHOST.into();
1914        let name = reverse_dns_name(&ip);
1915        assert!(
1916            name.ends_with(".ip6.arpa"),
1917            "must end with .ip6.arpa; got: {}",
1918            name
1919        );
1920        // The first nibble (most-reversed position) must be 1 (from ::1 low bit).
1921        assert!(
1922            name.starts_with("1."),
1923            "expected '1.' prefix, got: {}",
1924            name
1925        );
1926        // 32 nibbles + 31 dots + ".ip6.arpa" (9 chars) = 72.
1927        assert_eq!(name.len(), 72);
1928    }
1929
1930    // --- create_custom_resolver validation ---------------------------
1931
1932    #[tokio::test]
1933    async fn custom_resolver_rejects_invalid_input() {
1934        // After hostname support was added, a string that is neither a
1935        // valid IP nor a resolvable hostname should fail with a clear
1936        // "failed to resolve" error rather than panicking or hanging.
1937        // We pick a name that is *syntactically* impossible to resolve.
1938        let r = DnsResolver::new();
1939        let err = r.create_custom_resolver("..").await.unwrap_err();
1940        let msg = err.to_string().to_lowercase();
1941        assert!(
1942            msg.contains("dns resolution failed") || msg.contains("invalid"),
1943            "expected resolution failure, got: {}",
1944            msg
1945        );
1946    }
1947
1948    #[tokio::test]
1949    async fn custom_resolver_rejects_private_ipv4() {
1950        // SSRF defense: private / reserved ranges must be blocked even
1951        // when passed as a literal IP rather than a hostname.
1952        let r = DnsResolver::new();
1953        for reserved in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.169.254"] {
1954            let err = r.create_custom_resolver(reserved).await.unwrap_err();
1955            let msg = err.to_string().to_lowercase();
1956            assert!(
1957                msg.contains("blocked") || msg.contains("reserved"),
1958                "reserved IP {} must be rejected, got error: {}",
1959                reserved,
1960                msg
1961            );
1962        }
1963    }
1964
1965    #[tokio::test]
1966    async fn custom_resolver_rejects_loopback_ipv6() {
1967        let r = DnsResolver::new();
1968        let err = r.create_custom_resolver("::1").await.unwrap_err();
1969        let msg = err.to_string().to_lowercase();
1970        assert!(
1971            msg.contains("blocked") || msg.contains("reserved"),
1972            "::1 must be rejected, got error: {}",
1973            msg
1974        );
1975    }
1976
1977    #[tokio::test]
1978    async fn custom_resolver_accepts_public_ipv4() {
1979        // A known public resolver IP must be acceptable.
1980        let r = DnsResolver::new();
1981        let result = r.create_custom_resolver("8.8.8.8").await;
1982        assert!(
1983            result.is_ok(),
1984            "8.8.8.8 must be accepted as a public nameserver, got: {:?}",
1985            result.err()
1986        );
1987    }
1988
1989    // --- DoT/DoH: SSRF refusal parity ---------------------------------
1990    //
1991    // The reserved-IP guard must apply identically to tls:// and https://
1992    // specs — an encrypted transport is not a bypass of the SSRF policy.
1993
1994    #[tokio::test]
1995    async fn custom_resolver_rejects_private_ip_for_dot_and_doh() {
1996        let r = DnsResolver::new();
1997        for reserved in [
1998            "tls://127.0.0.1",
1999            "tls://192.168.1.1:853",
2000            "tls://[::1]",
2001            "https://10.0.0.1/dns-query",
2002            "https://169.254.169.254",
2003            "tls://[fd00::1]:853",
2004        ] {
2005            let err = r.create_custom_resolver(reserved).await.unwrap_err();
2006            let msg = err.to_string().to_lowercase();
2007            assert!(
2008                msg.contains("blocked") || msg.contains("reserved"),
2009                "reserved spec {} must be rejected, got error: {}",
2010                reserved,
2011                msg
2012            );
2013        }
2014        // DoH to an IPv6 literal is refused even earlier, at parse time
2015        // (it can never work — see `NameserverSpec::parse`).
2016        let err = r
2017            .create_custom_resolver("https://[fd00::1]:443/dns-query")
2018            .await
2019            .unwrap_err();
2020        assert!(matches!(err, SeerError::InvalidInput(_)), "{err:?}");
2021    }
2022
2023    #[tokio::test]
2024    async fn custom_resolver_accepts_public_dot_and_doh_literals() {
2025        // Construction (parse → validate → config build) must succeed for
2026        // public DoT/DoH IP literals without any network traffic.
2027        let r = DnsResolver::new();
2028        for spec in ["tls://1.1.1.1", "https://8.8.8.8/dns-query"] {
2029            let result = r.create_custom_resolver(spec).await;
2030            assert!(
2031                result.is_ok(),
2032                "{} must be accepted, got: {:?}",
2033                spec,
2034                result.err()
2035            );
2036        }
2037    }
2038
2039    #[tokio::test]
2040    async fn custom_resolver_rejects_unknown_scheme() {
2041        let r = DnsResolver::new();
2042        let err = r.create_custom_resolver("ftp://8.8.8.8").await.unwrap_err();
2043        assert!(
2044            matches!(err, SeerError::InvalidInput(_)),
2045            "unknown scheme must be an input error, got: {err:?}"
2046        );
2047    }
2048
2049    // --- build_upstream_config: protocol/port/tls-name construction ----
2050
2051    fn spec(s: &str) -> NameserverSpec {
2052        NameserverSpec::parse(s).unwrap_or_else(|e| panic!("{s:?} must parse: {e}"))
2053    }
2054
2055    #[test]
2056    fn tcp_only_swaps_udp_for_tcp_on_the_same_port() {
2057        let default = tcp_only(&default_upstream_config());
2058        assert!(!default.name_servers().is_empty());
2059        for server in default.name_servers() {
2060            assert_eq!(server.connections.len(), 1, "{server:?}");
2061            assert!(matches!(
2062                server.connections[0].protocol,
2063                ProtocolConfig::Tcp
2064            ));
2065            assert_eq!(server.connections[0].port, 53);
2066        }
2067
2068        let udp_only = tcp_only(&single_server_config(
2069            IpAddr::from([192, 0, 2, 1]),
2070            5353,
2071            false,
2072        ));
2073        let server = &udp_only.name_servers()[0];
2074        assert_eq!(server.connections.len(), 1);
2075        assert!(matches!(
2076            server.connections[0].protocol,
2077            ProtocolConfig::Tcp
2078        ));
2079        assert_eq!(server.connections[0].port, 5353);
2080    }
2081
2082    #[test]
2083    fn tcp_only_keeps_encrypted_transports() {
2084        let spec = NameserverSpec::parse("tls://1.1.1.1").unwrap();
2085        let config = build_upstream_config(&spec, &[IpAddr::from([1, 1, 1, 1])], None);
2086        let tcp = tcp_only(&config);
2087        assert_eq!(
2088            format!("{:?}", tcp.name_servers()),
2089            format!("{:?}", config.name_servers())
2090        );
2091    }
2092
2093    #[test]
2094    fn upstream_config_udp_defaults() {
2095        use hickory_resolver::config::ProtocolConfig;
2096
2097        let ip: IpAddr = "8.8.8.8".parse().unwrap();
2098        let config = build_upstream_config(&spec("8.8.8.8"), &[ip], None);
2099        let servers = config.name_servers();
2100        assert_eq!(servers.len(), 1);
2101        assert_eq!(servers[0].ip, ip);
2102        assert_eq!(servers[0].connections.len(), 1);
2103        assert_eq!(servers[0].connections[0].port, 53);
2104        assert!(matches!(
2105            servers[0].connections[0].protocol,
2106            ProtocolConfig::Udp
2107        ));
2108    }
2109
2110    #[test]
2111    fn upstream_config_udp_explicit_port() {
2112        let ip: IpAddr = "9.9.9.9".parse().unwrap();
2113        let config = build_upstream_config(&spec("9.9.9.9:5353"), &[ip], None);
2114        assert_eq!(config.name_servers()[0].connections[0].port, 5353);
2115    }
2116
2117    #[test]
2118    fn upstream_config_tls_sets_protocol_port_and_server_name() {
2119        use hickory_resolver::config::ProtocolConfig;
2120
2121        let ip: IpAddr = "9.9.9.9".parse().unwrap();
2122        let config = build_upstream_config(&spec("tls://dns.quad9.net"), &[ip], None);
2123        let ns = &config.name_servers()[0];
2124        assert_eq!(ns.ip, ip);
2125        assert_eq!(ns.connections.len(), 1);
2126        assert_eq!(ns.connections[0].port, 853);
2127        match &ns.connections[0].protocol {
2128            ProtocolConfig::Tls { server_name } => {
2129                assert_eq!(&**server_name, "dns.quad9.net");
2130            }
2131            other => panic!("expected Tls protocol, got {other:?}"),
2132        }
2133    }
2134
2135    #[test]
2136    fn upstream_config_tls_ip_literal_uses_ip_as_server_name() {
2137        use hickory_resolver::config::ProtocolConfig;
2138
2139        let ip: IpAddr = "1.1.1.1".parse().unwrap();
2140        let config = build_upstream_config(&spec("tls://1.1.1.1"), &[ip], None);
2141        match &config.name_servers()[0].connections[0].protocol {
2142            ProtocolConfig::Tls { server_name } => assert_eq!(&**server_name, "1.1.1.1"),
2143            other => panic!("expected Tls protocol, got {other:?}"),
2144        }
2145    }
2146
2147    #[test]
2148    fn upstream_config_https_sets_protocol_port_path_and_server_name() {
2149        use hickory_resolver::config::ProtocolConfig;
2150
2151        let ip: IpAddr = "104.16.248.249".parse().unwrap();
2152        let config = build_upstream_config(&spec("https://cloudflare-dns.com"), &[ip], None);
2153        let ns = &config.name_servers()[0];
2154        assert_eq!(ns.connections[0].port, 443);
2155        match &ns.connections[0].protocol {
2156            ProtocolConfig::Https { server_name, path } => {
2157                assert_eq!(&**server_name, "cloudflare-dns.com");
2158                assert_eq!(&**path, "/dns-query");
2159            }
2160            other => panic!("expected Https protocol, got {other:?}"),
2161        }
2162    }
2163
2164    #[test]
2165    fn upstream_config_https_custom_port_and_path() {
2166        use hickory_resolver::config::ProtocolConfig;
2167
2168        let ip: IpAddr = "8.8.8.8".parse().unwrap();
2169        let config = build_upstream_config(&spec("https://dns.google:8443/resolve"), &[ip], None);
2170        let ns = &config.name_servers()[0];
2171        assert_eq!(ns.connections[0].port, 8443);
2172        match &ns.connections[0].protocol {
2173            ProtocolConfig::Https { server_name, path } => {
2174                assert_eq!(&**server_name, "dns.google");
2175                assert_eq!(&**path, "/resolve");
2176            }
2177            other => panic!("expected Https protocol, got {other:?}"),
2178        }
2179    }
2180
2181    #[test]
2182    fn upstream_config_multiple_ips_share_spec() {
2183        // A hostname spec resolving to several addresses gets one upstream
2184        // per IP, all speaking the same protocol/port/TLS name.
2185        use hickory_resolver::config::ProtocolConfig;
2186
2187        let ips: Vec<IpAddr> = vec![
2188            "9.9.9.9".parse().unwrap(),
2189            "149.112.112.112".parse().unwrap(),
2190        ];
2191        let config = build_upstream_config(&spec("tls://dns.quad9.net"), &ips, None);
2192        let servers = config.name_servers();
2193        assert_eq!(servers.len(), 2);
2194        for (ns, expected_ip) in servers.iter().zip(&ips) {
2195            assert_eq!(&ns.ip, expected_ip);
2196            assert_eq!(ns.connections[0].port, 853);
2197            assert!(matches!(
2198                &ns.connections[0].protocol,
2199                ProtocolConfig::Tls { server_name } if &**server_name == "dns.quad9.net"
2200            ));
2201        }
2202    }
2203
2204    /// Regression: every dual-stack hostname nameserver (`dns.google`,
2205    /// `tls://one.one.one.one`, `https://cloudflare-dns.com/dns-query`) timed
2206    /// out on hosts with an IPv6 default route but no IPv6 transit, while IP
2207    /// literals worked. The bootstrap `lookup_ip` returns AAAA before A, the
2208    /// upstream list kept that order, and the pool's first parallel pair was
2209    /// both IPv6 — which spent the whole per-query deadline before any IPv4
2210    /// entry was tried.
2211    #[test]
2212    fn upstream_config_orders_ipv4_before_ipv6() {
2213        // As hickory's `lookup_ip` returns them: AAAA records first.
2214        let ips: Vec<IpAddr> = [
2215            "2606:4700::6810:f8f9",
2216            "2606:4700::6810:f9f9",
2217            "104.16.248.249",
2218            "104.16.249.249",
2219        ]
2220        .iter()
2221        .map(|s| s.parse().unwrap())
2222        .collect();
2223        let config =
2224            build_upstream_config(&spec("https://cloudflare-dns.com/dns-query"), &ips, None);
2225        let order: Vec<IpAddr> = config.name_servers().iter().map(|ns| ns.ip).collect();
2226        assert_eq!(
2227            order,
2228            [ips[2], ips[3], ips[0], ips[1]],
2229            "IPv4 first, each family in resolved order, nothing dropped"
2230        );
2231        let concurrent = ResolverOpts::default().num_concurrent_reqs.max(1);
2232        assert!(
2233            order.iter().take(concurrent).all(IpAddr::is_ipv4),
2234            "the parallel window must be all IPv4: {order:?}"
2235        );
2236    }
2237
2238    #[test]
2239    fn upstream_config_test_port_override_wins() {
2240        // The #[cfg(test)] mock-server seam must override the spec's port.
2241        let ip: IpAddr = "127.0.0.1".parse().unwrap();
2242        let config = build_upstream_config(&spec("127.0.0.1"), &[ip], Some(9999));
2243        assert_eq!(config.name_servers()[0].connections[0].port, 9999);
2244    }
2245
2246    #[tokio::test]
2247    async fn query_asks_dot_and_doh_servers_through_the_vetted_config() {
2248        // `query` sends to the servers of `custom_upstream_config` — the one
2249        // the resolver for `resolve` is built from — so a DoT or DoH spec is
2250        // asked over TLS, with its host as the TLS server name (checked
2251        // against the certificate), and DoH on its path. Built without any
2252        // network traffic for IP literals.
2253        use hickory_resolver::config::ProtocolConfig;
2254
2255        let r = DnsResolver::new();
2256        let dot = r.custom_upstream_config("tls://1.1.1.1").await.unwrap();
2257        let [server] = dot.name_servers() else {
2258            panic!("one server per address: {dot:?}");
2259        };
2260        assert_eq!(server.ip, IpAddr::from([1, 1, 1, 1]));
2261        assert_eq!(server.connections.len(), 1);
2262        assert_eq!(server.connections[0].port, 853);
2263        assert!(matches!(
2264            &server.connections[0].protocol,
2265            ProtocolConfig::Tls { server_name } if &**server_name == "1.1.1.1"
2266        ));
2267
2268        let doh = r
2269            .custom_upstream_config("https://8.8.8.8/dns-query")
2270            .await
2271            .unwrap();
2272        let [server] = doh.name_servers() else {
2273            panic!("one server per address: {doh:?}");
2274        };
2275        assert_eq!(server.connections[0].port, 443);
2276        assert!(matches!(
2277            &server.connections[0].protocol,
2278            ProtocolConfig::Https { server_name, path }
2279                if &**server_name == "8.8.8.8" && &**path == "/dns-query"
2280        ));
2281
2282        // A bare spec is UDP on port 53 (a truncated reply is repeated over
2283        // TCP by the transport, not configured here).
2284        let udp = r.custom_upstream_config("9.9.9.9").await.unwrap();
2285        assert_eq!(udp.name_servers()[0].connections[0].port, 53);
2286        assert!(matches!(
2287            udp.name_servers()[0].connections[0].protocol,
2288            ProtocolConfig::Udp
2289        ));
2290        // The default upstream is Google's, IPv4 first.
2291        let Upstream::Direct { config, .. } = r.query_upstream("seer.test", None).await.unwrap()
2292        else {
2293            panic!("a public name is sent to the default upstream");
2294        };
2295        assert!(config.name_servers()[0].ip.is_ipv4());
2296    }
2297
2298    // --- Live DoT/DoH queries (opt-in only) ----------------------------
2299
2300    #[tokio::test]
2301    #[ignore = "live network — DoT query against Cloudflare"]
2302    async fn live_query_over_dot() {
2303        let result = DnsResolver::new()
2304            .query("example.com", RecordType::A, Some("tls://1.1.1.1"))
2305            .await
2306            .expect("DoT query should get a response");
2307        assert_eq!(result.status, DnsStatus::NoError);
2308        assert!(result.flags.contains(&"qr".to_string()), "{result:?}");
2309        assert!(result.records().count() > 0, "expected A records over DoT");
2310    }
2311
2312    #[tokio::test]
2313    #[ignore = "live network — DoH query against Cloudflare"]
2314    async fn live_query_over_doh() {
2315        let result = DnsResolver::new()
2316            .query(
2317                "example.com",
2318                RecordType::A,
2319                Some("https://cloudflare-dns.com/dns-query"),
2320            )
2321            .await
2322            .expect("DoH query should get a response");
2323        assert_eq!(result.status, DnsStatus::NoError);
2324        assert!(result.records().count() > 0, "expected A records over DoH");
2325    }
2326
2327    #[tokio::test]
2328    #[ignore = "live network — NXDOMAIN keeps its header"]
2329    async fn live_query_nxdomain_has_flags_and_the_soa() {
2330        let result = DnsResolver::new()
2331            // `.example` is reserved (RFC 2606): the root says NXDOMAIN.
2332            .query("seer-does-not-exist.example", RecordType::A, None)
2333            .await
2334            .expect("a negative answer is a result");
2335        assert_eq!(result.status, DnsStatus::NxDomain);
2336        assert!(result.flags.contains(&"qr".to_string()), "{result:?}");
2337        assert!(
2338            result
2339                .authority
2340                .iter()
2341                .any(|r| r.record_type == RecordType::SOA),
2342            "{result:?}"
2343        );
2344    }
2345
2346    #[tokio::test]
2347    #[ignore = "live network — DoT query against Cloudflare"]
2348    async fn live_resolve_over_dot() {
2349        let r = DnsResolver::new();
2350        let records = r
2351            .resolve("example.com", RecordType::A, Some("tls://1.1.1.1"))
2352            .await
2353            .expect("DoT lookup should succeed");
2354        assert!(!records.is_empty(), "expected A records over DoT");
2355    }
2356
2357    #[tokio::test]
2358    #[ignore = "live network — DoH query against Cloudflare"]
2359    async fn live_resolve_over_doh() {
2360        let r = DnsResolver::new();
2361        let records = r
2362            .resolve(
2363                "example.com",
2364                RecordType::A,
2365                Some("https://cloudflare-dns.com/dns-query"),
2366            )
2367            .await
2368            .expect("DoH lookup should succeed");
2369        assert!(!records.is_empty(), "expected A records over DoH");
2370    }
2371
2372    // --- SRV query validation (integration between helper + resolver) ----
2373
2374    #[tokio::test]
2375    async fn resolve_srv_rejects_invalid_service_label() {
2376        let r = DnsResolver::new();
2377        // With_dot service name would construct a malformed DNS query.
2378        let result = r
2379            .resolve_srv_core(&r.default_target(), "http.evil", "tcp", "example.com")
2380            .await;
2381        assert!(result.is_err());
2382        let msg = result.unwrap_err().to_string().to_lowercase();
2383        assert!(
2384            msg.contains("invalid srv service"),
2385            "expected SRV service validation error, got: {}",
2386            msg
2387        );
2388    }
2389
2390    #[tokio::test]
2391    async fn resolve_srv_rejects_invalid_protocol_label() {
2392        let r = DnsResolver::new();
2393        let result = r
2394            .resolve_srv_core(&r.default_target(), "http", "tcp.evil", "example.com")
2395            .await;
2396        assert!(result.is_err());
2397        let msg = result.unwrap_err().to_string().to_lowercase();
2398        assert!(
2399            msg.contains("invalid srv protocol"),
2400            "expected SRV protocol validation error, got: {}",
2401            msg
2402        );
2403    }
2404
2405    #[tokio::test]
2406    async fn resolve_srv_normalizes_and_validates_domain_input() {
2407        // SRV names must be normalized/validated like every other query, or
2408        // garbage input reaches query construction as a (misclassified) DNS
2409        // failure instead of an input error (2026-07-11 review).
2410        let r = DnsResolver::new();
2411        let result = r
2412            .resolve("_http._tcp.not a valid domain", RecordType::SRV, None)
2413            .await;
2414        assert!(
2415            matches!(result, Err(SeerError::InvalidDomain(_))),
2416            "expected InvalidDomain from domain validation, got: {result:?}"
2417        );
2418    }
2419
2420    // --- Normalization applied before resolution ---------------------
2421
2422    #[tokio::test]
2423    async fn resolve_normalizes_uppercase_domain_input() {
2424        // We can't hit the network in unit tests, but we can at least
2425        // assert that normalization rejects clearly-invalid input
2426        // before any network call is made. Domains with a leading `.`
2427        // are rejected by the normalizer.
2428        let r = DnsResolver::new();
2429        let result = r.resolve(".bad.example", RecordType::A, None).await;
2430        assert!(result.is_err(), "leading-dot domain must be rejected");
2431    }
2432
2433    // --- SRV record -------------------------------------------------
2434
2435    // --- SRV via dig-style names (parse_srv_query) -------------------
2436
2437    #[test]
2438    fn parse_srv_query_extracts_service_proto_and_name() {
2439        assert_eq!(
2440            parse_srv_query("_sip._tcp.example.com"),
2441            Some((
2442                "sip".to_string(),
2443                "tcp".to_string(),
2444                "example.com".to_string()
2445            ))
2446        );
2447    }
2448
2449    #[test]
2450    fn parse_srv_query_keeps_multilabel_domain() {
2451        assert_eq!(
2452            parse_srv_query("_sip._tcp.sip.voice.google.com"),
2453            Some((
2454                "sip".to_string(),
2455                "tcp".to_string(),
2456                "sip.voice.google.com".to_string()
2457            ))
2458        );
2459    }
2460
2461    #[test]
2462    fn fqdn_appends_root_dot_once() {
2463        assert_eq!(fqdn("example.com"), "example.com.");
2464        assert_eq!(fqdn("example.com."), "example.com.");
2465    }
2466
2467    #[test]
2468    fn parse_srv_query_rejects_bare_domain() {
2469        assert_eq!(parse_srv_query("example.com"), None);
2470    }
2471
2472    #[test]
2473    fn parse_srv_query_rejects_missing_proto_label() {
2474        // Second label must be an `_proto` label.
2475        assert_eq!(parse_srv_query("_sip.example.com"), None);
2476    }
2477
2478    #[tokio::test]
2479    async fn resolve_rejects_bare_domain_for_srv_as_input_error() {
2480        // A bare domain (no _service._proto labels) cannot be an SRV query.
2481        // This is a usage/input error — NOT a transient DNS failure — so it
2482        // must surface as InvalidInput (which maps to a permanent, non-retryable
2483        // signal across the Python/MCP boundary), and still carry the hint.
2484        let r = DnsResolver::new();
2485        let err = r
2486            .resolve("example.com", RecordType::SRV, None)
2487            .await
2488            .expect_err("bare-domain SRV must error");
2489        assert!(
2490            matches!(err, SeerError::InvalidInput(_)),
2491            "bare-domain SRV should be an input error, got: {err:?}"
2492        );
2493        assert!(err.to_string().contains("_service._proto"));
2494    }
2495
2496    #[tokio::test]
2497    #[ignore = "live network"]
2498    async fn resolve_srv_via_dig_style_name_returns_records() {
2499        // _caldavs._tcp.google.com is a long-standing public SRV record
2500        // (CalDAV discovery → calendar.google.com:443).
2501        let r = DnsResolver::new();
2502        let records = r
2503            .resolve("_caldavs._tcp.google.com", RecordType::SRV, None)
2504            .await
2505            .expect("dig-style SRV lookup should succeed");
2506        assert!(!records.is_empty(), "expected SRV records");
2507        assert!(records.iter().all(|r| r.record_type == RecordType::SRV));
2508    }
2509
2510    #[tokio::test]
2511    #[ignore = "live network"]
2512    async fn resolve_naptr_returns_records() {
2513        // sip2sip.info publishes stable NAPTR records for SIP discovery.
2514        let r = DnsResolver::new();
2515        let records = r
2516            .resolve("sip2sip.info", RecordType::NAPTR, None)
2517            .await
2518            .expect("NAPTR lookup should succeed");
2519        assert!(!records.is_empty(), "expected NAPTR records");
2520        assert!(records.iter().all(|r| r.record_type == RecordType::NAPTR));
2521    }
2522
2523    // --- prepare_query: PTR must accept raw IP literals (incl. IPv6) --
2524
2525    #[test]
2526    fn prepare_query_passes_ipv6_literal_through_for_ptr() {
2527        // Regression: normalize_domain's port-strip heuristic mangled IPv6
2528        // literals (the trailing `:1111` group looks like a `:port`), so IPv6
2529        // reverse lookups failed with "Invalid domain name" before ever
2530        // reaching resolve_ptr. PTR queries for IP literals must bypass domain
2531        // normalization.
2532        let out = prepare_query("2606:4700:4700::1111", RecordType::PTR).unwrap();
2533        assert_eq!(out, "2606:4700:4700::1111");
2534    }
2535
2536    #[test]
2537    fn prepare_query_passes_ipv6_loopback_through_for_ptr() {
2538        let out = prepare_query("::1", RecordType::PTR).unwrap();
2539        assert_eq!(out, "::1");
2540    }
2541
2542    #[test]
2543    fn prepare_query_passes_ipv4_literal_through_for_ptr() {
2544        let out = prepare_query("8.8.8.8", RecordType::PTR).unwrap();
2545        assert_eq!(out, "8.8.8.8");
2546    }
2547
2548    #[test]
2549    fn prepare_query_normalizes_non_ip_ptr_names() {
2550        // A reverse-DNS name (not an IP literal) still gets normalized.
2551        let out = prepare_query("1.1.1.1.in-addr.arpa", RecordType::PTR).unwrap();
2552        assert_eq!(out, "1.1.1.1.in-addr.arpa");
2553    }
2554
2555    #[test]
2556    fn prepare_query_keeps_www_for_record_queries() {
2557        // Regression: record queries normalized with `normalize_domain`,
2558        // which strips `www.` — so `dig www.example.com CNAME` silently
2559        // queried the apex. A record query is about the exact name.
2560        let out = prepare_query("HTTPS://WWW.Example.com/path", RecordType::A).unwrap();
2561        assert_eq!(out, "www.example.com");
2562        let out = prepare_query("www.example.com", RecordType::CNAME).unwrap();
2563        assert_eq!(out, "www.example.com");
2564    }
2565
2566    #[test]
2567    fn prepare_query_still_normalizes_scheme_port_path_and_case() {
2568        let out = prepare_query("https://API.Example.COM:8443/v1?q=1#frag", RecordType::A).unwrap();
2569        assert_eq!(out, "api.example.com");
2570        let out = prepare_query("example.com.", RecordType::MX).unwrap();
2571        assert_eq!(out, "example.com");
2572        assert!(prepare_query("not a domain", RecordType::A).is_err());
2573    }
2574
2575    #[test]
2576    fn prepare_query_accepts_a_leading_wildcard_label() {
2577        // Regression: `dig *.example.com` failed with "Invalid domain name"
2578        // because the query name went through the host normalizer, which
2579        // rejects `*`. Querying a wildcard owner name is ordinary DNS.
2580        let out = prepare_query("*.Example.com.", RecordType::A).unwrap();
2581        assert_eq!(out, "*.example.com");
2582        let out = prepare_query("*.1.168.192.in-addr.arpa", RecordType::PTR).unwrap();
2583        assert_eq!(out, "*.1.168.192.in-addr.arpa");
2584        // Only the whole leftmost label may be `*`.
2585        assert!(prepare_query("a.*.example.com", RecordType::A).is_err());
2586        assert!(prepare_query("a*.example.com", RecordType::A).is_err());
2587    }
2588
2589    #[test]
2590    fn prepare_query_runs_ptr_ip_literal_through_the_normalizer() {
2591        // Regression: an IP literal skipped the normalizer entirely, and with
2592        // it the `SEER_DOMAIN_ALLOWLIST` check that lives inside — so a PTR
2593        // query for an IP bypassed the allowlist that the equivalent
2594        // `in-addr.arpa` query obeys. Simulate an allowlist that excludes
2595        // the reverse zones with a rejecting normalizer.
2596        let deny_arpa = |name: &str| -> Result<String> {
2597            if name.ends_with(".arpa") {
2598                Err(SeerError::DomainNotAllowed {
2599                    domain: name.to_string(),
2600                    tld: "arpa".to_string(),
2601                })
2602            } else {
2603                Ok(name.to_string())
2604            }
2605        };
2606        for ip in ["192.0.2.1", "2606:4700:4700::1111"] {
2607            let err = prepare_query_with(ip, RecordType::PTR, deny_arpa)
2608                .expect_err("PTR for an IP literal must obey the normalizer");
2609            assert!(matches!(err, SeerError::DomainNotAllowed { .. }), "{err:?}");
2610        }
2611        // With the real normalizer (no allowlist in tests) both still pass.
2612        assert_eq!(
2613            prepare_query_with("192.0.2.1", RecordType::PTR, normalize_query_name).unwrap(),
2614            "192.0.2.1"
2615        );
2616    }
2617
2618    #[test]
2619    fn from_wire_type_inverts_wire_type_for_every_single_type() {
2620        for record_type in RecordType::ALL.iter().copied() {
2621            match wire_type(record_type) {
2622                Some(wire) => assert_eq!(from_wire_type(wire), Some(record_type)),
2623                None => assert_eq!(record_type, RecordType::ANY),
2624            }
2625        }
2626        // SRV has a wire type (so an SRV answer converts under its owner);
2627        // types seer does not model have none.
2628        assert_eq!(
2629            from_wire_type(HickoryRecordType::SRV),
2630            Some(RecordType::SRV)
2631        );
2632        assert_eq!(from_wire_type(HickoryRecordType::RRSIG), None);
2633    }
2634
2635    #[test]
2636    fn to_dns_record_keeps_the_real_owner_and_type() {
2637        let cname = Record::from_rdata(
2638            Name::from_ascii("www.seer.test.").unwrap(),
2639            300,
2640            HickoryRData::CNAME(hickory_resolver::proto::rr::rdata::CNAME(
2641                Name::from_ascii("edge.cdn.test.").unwrap(),
2642            )),
2643        );
2644        let converted = to_dns_record(&cname).expect("CNAME is modeled");
2645        assert_eq!(converted.name, "www.seer.test");
2646        assert_eq!(converted.record_type, RecordType::CNAME);
2647        assert_eq!(converted.ttl, 300);
2648        assert_eq!(converted.data.to_string(), "edge.cdn.test.");
2649
2650        let a = Record::from_rdata(
2651            Name::from_ascii("edge.cdn.test.").unwrap(),
2652            60,
2653            HickoryRData::A(hickory_resolver::proto::rr::rdata::A(Ipv4Addr::new(
2654                192, 0, 2, 7,
2655            ))),
2656        );
2657        let converted = to_dns_record(&a).expect("A is modeled");
2658        assert_eq!(converted.name, "edge.cdn.test");
2659        assert_eq!(converted.record_type, RecordType::A);
2660
2661        assert_eq!(owner_name(&Name::root()), ".");
2662        // An IDN owner keeps its A-label, the spelling of the query name.
2663        assert_eq!(
2664            owner_name(&Name::from_utf8("bücher.seer.test.").unwrap()),
2665            "xn--bcher-kva.seer.test"
2666        );
2667    }
2668
2669    #[test]
2670    fn to_dns_record_spells_rdata_names_as_a_labels_where_resolve_keeps_unicode() {
2671        use hickory_resolver::proto::rr::rdata::{CNAME, MX, SOA, SRV};
2672        let idn = |name: &str| Name::from_utf8(name).unwrap();
2673        let data = |rdata: HickoryRData| {
2674            let record = Record::from_rdata(idn("xn--bcher-kva.seer.test."), 300, rdata);
2675            to_dns_record(&record).expect("modeled").data.to_string()
2676        };
2677        // Regression: RDATA names used hickory's Unicode `Display`, so a
2678        // CNAME target (`edge.café.test.`) never equalled the next hop's
2679        // A-label owner (`edge.xn--caf-dma.test`).
2680        assert_eq!(
2681            data(HickoryRData::CNAME(CNAME(idn("edge.café.test.")))),
2682            "edge.xn--caf-dma.test."
2683        );
2684        assert_eq!(
2685            data(HickoryRData::MX(MX::new(10, idn("mail.café.test.")))),
2686            "10 mail.xn--caf-dma.test."
2687        );
2688        assert_eq!(
2689            data(HickoryRData::SRV(SRV::new(
2690                1,
2691                5,
2692                443,
2693                idn("sip.café.test.")
2694            ))),
2695            "1 5 443 sip.xn--caf-dma.test."
2696        );
2697        assert_eq!(
2698            data(HickoryRData::SOA(SOA::new(
2699                idn("ns1.café.test."),
2700                idn("hostmaster.café.test."),
2701                1,
2702                7200,
2703                3600,
2704                1209600,
2705                300,
2706            ))),
2707            "ns1.xn--caf-dma.test. hostmaster.xn--caf-dma.test. 1 7200 3600 1209600 300"
2708        );
2709
2710        // `resolve`'s conversion keeps its long-standing Unicode spelling.
2711        let cname = HickoryRData::CNAME(CNAME(idn("edge.café.test.")));
2712        assert_eq!(
2713            convert_rdata(RecordType::CNAME, &cname, NameSpelling::Display)
2714                .expect("modeled")
2715                .to_string(),
2716            "edge.café.test."
2717        );
2718    }
2719
2720    #[test]
2721    fn parse_caa_keeps_reserved_flag_bits() {
2722        // Regression: flags were rebuilt from `issuer_critical` alone, so a
2723        // record published with reserved bits set reported flags=128/0.
2724        let mut caa = CAA::new_issue(
2725            true,
2726            Some(Name::from_ascii("letsencrypt.org").unwrap()),
2727            vec![],
2728        );
2729        caa.reserved_flags = 0x01;
2730        let (flags, tag, value) = parse_caa(&caa);
2731        assert_eq!(flags, 0x81);
2732        assert_eq!(tag, "issue");
2733        assert_eq!(value, "letsencrypt.org");
2734    }
2735
2736    #[test]
2737    fn alpn_ids_are_escaped_at_both_levels() {
2738        use hickory_resolver::proto::rr::rdata::svcb::Alpn;
2739        let alpn = |ids: &[&str]| {
2740            svc_param_value(&SvcParamValue::Alpn(Alpn(
2741                ids.iter().map(|id| id.to_string()).collect(),
2742            )))
2743        };
2744        assert_eq!(alpn(&["h3", "h2"]), "h3,h2");
2745        // Regression: a comma or backslash inside an alpn-id was escaped once,
2746        // so a parser's character-string pass consumed the escape and the
2747        // value-list pass then split `a,b` in two and dropped the `\`. The
2748        // expected strings are raw: `a\\,b` and `x\\\\y` as written here.
2749        assert_eq!(alpn(&["a,b"]), r"a\\,b");
2750        assert_eq!(alpn(&[r"x\y"]), r"x\\\\y");
2751        // RFC 9460 Appendix D.2's vector: alpn-ids `f\oo,bar` and `h2`.
2752        assert_eq!(alpn(&[r"f\oo,bar", "h2"]), r"f\\\\oo\\,bar,h2");
2753    }
2754
2755    // --- Hermetic mock-server tests -----------------------------------
2756    //
2757    // These exercise the full resolve() / query() path (normalization →
2758    // custom-resolver construction → hickory transport → RData conversion)
2759    // against the shared loopback fixture in `crate::dns::test_support`,
2760    // without touching the network. The SSRF guards deliberately refuse
2761    // loopback, so the resolver under test uses the `#[cfg(test)]`-only
2762    // `allowing_private_hosts` / `with_port` seams, which do not exist in
2763    // release builds.
2764
2765    use crate::dns::query::DnsStatus;
2766    use crate::dns::test_support::{
2767        a_rdata, cname_rdata, mock_dns_resolver, mock_dns_resolver_default, record, spawn_mock_dns,
2768        spawn_mock_dns_fn, spawn_mock_dns_fn_with_tcp, spawn_mock_dns_split, MockMode, MockReply,
2769    };
2770    use hickory_resolver::proto::op::ResponseCode;
2771
2772    async fn mock_zone_lookup(record_type: RecordType, domain: &str) -> Vec<DnsRecord> {
2773        let port = spawn_mock_dns(MockMode::Zone).await;
2774        mock_dns_resolver(port)
2775            .resolve(domain, record_type, Some("127.0.0.1"))
2776            .await
2777            .unwrap_or_else(|e| panic!("{record_type} lookup against mock must succeed: {e}"))
2778    }
2779
2780    /// A loopback server answering every query with one A record, and the
2781    /// count of queries it has received.
2782    async fn counting_server() -> (u16, Arc<std::sync::atomic::AtomicUsize>) {
2783        use std::sync::atomic::{AtomicUsize, Ordering};
2784        let queries = Arc::new(AtomicUsize::new(0));
2785        let seen = queries.clone();
2786        let port = spawn_mock_dns_fn(move |_, _| {
2787            seen.fetch_add(1, Ordering::SeqCst);
2788            MockReply::Answer(vec![a_rdata([192, 0, 2, 1])])
2789        })
2790        .await;
2791        (port, queries)
2792    }
2793
2794    /// Regression: a custom nameserver got a new resolver per `resolve()`,
2795    /// so 100 lookups of one name sent 100 queries where the default
2796    /// upstream sent one. The resolver is now reused, record cache and all,
2797    /// and lookups racing to build it share one.
2798    #[tokio::test]
2799    async fn a_custom_nameservers_resolver_is_reused() {
2800        use std::sync::atomic::Ordering;
2801        let (port, queries) = counting_server().await;
2802        let resolver = mock_dns_resolver(port);
2803        let ns = Some("127.0.0.1");
2804
2805        // A clone shares the memo.
2806        let clone = resolver.clone();
2807        let (first, second) = tokio::join!(
2808            resolver.resolve("seer.test", RecordType::A, ns),
2809            clone.resolve("seer.test", RecordType::A, ns),
2810        );
2811        assert_eq!(first.unwrap().len(), 1);
2812        assert_eq!(second.unwrap().len(), 1);
2813        for _ in 0..10 {
2814            resolver
2815                .resolve("seer.test", RecordType::A, ns)
2816                .await
2817                .unwrap();
2818        }
2819        assert_eq!(queries.load(Ordering::SeqCst), 1);
2820    }
2821
2822    /// `resolve_uncached` (follow, doctor) asks the server every time.
2823    #[tokio::test]
2824    async fn resolve_uncached_asks_the_server_every_time() {
2825        use std::sync::atomic::Ordering;
2826        let (port, queries) = counting_server().await;
2827        let resolver = mock_dns_resolver(port);
2828        resolver
2829            .resolve("seer.test", RecordType::A, Some("127.0.0.1"))
2830            .await
2831            .unwrap();
2832        for _ in 0..3 {
2833            resolver
2834                .resolve_uncached("seer.test", RecordType::A, Some("127.0.0.1"))
2835                .await
2836                .unwrap();
2837        }
2838        assert_eq!(queries.load(Ordering::SeqCst), 4);
2839    }
2840
2841    /// The memo holds at most `MAX_CUSTOM_RESOLVERS` specs, dropping the
2842    /// oldest, and hands back a live spec's own slot.
2843    #[test]
2844    fn the_custom_resolver_memo_is_bounded() {
2845        let memo = CustomResolvers::default();
2846        let first = memo.slot("ns0");
2847        assert!(Arc::ptr_eq(&first, &memo.slot("ns0")));
2848        for i in 1..=MAX_CUSTOM_RESOLVERS {
2849            memo.slot(&format!("ns{i}"));
2850        }
2851        let slots = memo.slots.lock().unwrap();
2852        assert_eq!(slots.len(), MAX_CUSTOM_RESOLVERS);
2853        assert!(!slots.contains_key("ns0"), "the oldest is dropped");
2854    }
2855
2856    #[tokio::test]
2857    async fn mock_resolve_a_returns_addresses() {
2858        let records = mock_zone_lookup(RecordType::A, "seer.test").await;
2859        assert_eq!(records.len(), 2);
2860        assert!(records.iter().all(|r| r.record_type == RecordType::A));
2861        assert_eq!(records[0].name, "seer.test");
2862        assert_eq!(records[0].ttl, 300);
2863        let addresses: Vec<String> = records
2864            .iter()
2865            .map(|r| match &r.data {
2866                RecordData::A { address } => address.clone(),
2867                other => panic!("expected A data, got {other:?}"),
2868            })
2869            .collect();
2870        assert!(addresses.contains(&"192.0.2.1".to_string()));
2871        assert!(addresses.contains(&"192.0.2.2".to_string()));
2872    }
2873
2874    #[tokio::test]
2875    async fn mock_resolve_mx_sorts_by_preference() {
2876        let records = mock_zone_lookup(RecordType::MX, "seer.test").await;
2877        let prefs: Vec<u16> = records
2878            .iter()
2879            .map(|r| match &r.data {
2880                RecordData::MX { preference, .. } => *preference,
2881                other => panic!("expected MX data, got {other:?}"),
2882            })
2883            .collect();
2884        // The zone serves 30, 10, 20 — resolve() must sort ascending.
2885        assert_eq!(prefs, vec![10, 20, 30]);
2886        assert!(matches!(
2887            &records[0].data,
2888            RecordData::MX { exchange, .. } if exchange == "a.mail.seer.test."
2889        ));
2890    }
2891
2892    #[tokio::test]
2893    async fn mock_resolve_txt_joins_character_strings() {
2894        let records = mock_zone_lookup(RecordType::TXT, "seer.test").await;
2895        assert_eq!(records.len(), 1);
2896        match &records[0].data {
2897            RecordData::TXT { text, strings } => {
2898                assert_eq!(text, "v=spf1 -all");
2899                assert_eq!(strings, &["v=spf1 ", "-all"], "the strings as sent");
2900            }
2901            other => panic!("expected TXT data, got {other:?}"),
2902        }
2903        assert_eq!(records[0].data.to_string(), r#""v=spf1 " "-all""#);
2904    }
2905
2906    #[tokio::test]
2907    async fn mock_resolve_soa_maps_all_fields() {
2908        let records = mock_zone_lookup(RecordType::SOA, "seer.test").await;
2909        assert_eq!(records.len(), 1);
2910        match &records[0].data {
2911            RecordData::SOA {
2912                mname,
2913                rname,
2914                serial,
2915                refresh,
2916                retry,
2917                expire,
2918                minimum,
2919            } => {
2920                assert_eq!(mname, "ns1.seer.test.");
2921                assert_eq!(rname, "hostmaster.seer.test.");
2922                assert_eq!(*serial, 2026070101);
2923                assert_eq!(*refresh, 7200);
2924                assert_eq!(*retry, 3600);
2925                assert_eq!(*expire, 1209600);
2926                assert_eq!(*minimum, 300);
2927            }
2928            other => panic!("expected SOA data, got {other:?}"),
2929        }
2930    }
2931
2932    #[tokio::test]
2933    async fn mock_resolve_caa_maps_flags_tag_and_value() {
2934        let records = mock_zone_lookup(RecordType::CAA, "seer.test").await;
2935        assert_eq!(records.len(), 2);
2936        let by_tag = |wanted: &str| {
2937            records
2938                .iter()
2939                .find_map(|r| match &r.data {
2940                    RecordData::CAA { flags, tag, value } if tag == wanted => {
2941                        Some((*flags, value.clone()))
2942                    }
2943                    _ => None,
2944                })
2945                .unwrap_or_else(|| panic!("expected a CAA record with tag {wanted}"))
2946        };
2947        // issuer_critical=false → flags 0; true → 128 (RFC 8659 critical bit).
2948        assert_eq!(by_tag("issue"), (0, "letsencrypt.org".to_string()));
2949        assert_eq!(
2950            by_tag("iodef"),
2951            (128, "mailto:security@seer.test".to_string())
2952        );
2953    }
2954
2955    #[tokio::test]
2956    async fn mock_resolve_tlsa_hex_encodes_cert_data() {
2957        let records = mock_zone_lookup(RecordType::TLSA, "_443._tcp.seer.test").await;
2958        assert_eq!(records.len(), 1);
2959        match &records[0].data {
2960            RecordData::TLSA {
2961                cert_usage,
2962                selector,
2963                matching,
2964                cert_data,
2965            } => {
2966                assert_eq!((*cert_usage, *selector, *matching), (3, 1, 1));
2967                assert_eq!(cert_data, "ABCD01");
2968            }
2969            other => panic!("expected TLSA data, got {other:?}"),
2970        }
2971    }
2972
2973    #[tokio::test]
2974    async fn mock_resolve_sshfp_hex_encodes_fingerprint() {
2975        let records = mock_zone_lookup(RecordType::SSHFP, "seer.test").await;
2976        assert_eq!(records.len(), 1);
2977        match &records[0].data {
2978            RecordData::SSHFP {
2979                algorithm,
2980                fingerprint_type,
2981                fingerprint,
2982            } => {
2983                assert_eq!((*algorithm, *fingerprint_type), (4, 2));
2984                assert_eq!(fingerprint, "DEADBEEF");
2985            }
2986            other => panic!("expected SSHFP data, got {other:?}"),
2987        }
2988    }
2989
2990    #[tokio::test]
2991    async fn mock_resolve_naptr_decodes_character_strings() {
2992        let records = mock_zone_lookup(RecordType::NAPTR, "seer.test").await;
2993        assert_eq!(records.len(), 1);
2994        match &records[0].data {
2995            RecordData::NAPTR {
2996                order,
2997                preference,
2998                flags,
2999                services,
3000                regexp,
3001                replacement,
3002            } => {
3003                assert_eq!((*order, *preference), (100, 50));
3004                assert_eq!(flags, "U");
3005                assert_eq!(services, "E2U+sip");
3006                assert_eq!(regexp, "!^.*$!sip:info@seer.test!");
3007                assert_eq!(replacement, ".");
3008            }
3009            other => panic!("expected NAPTR data, got {other:?}"),
3010        }
3011    }
3012
3013    #[tokio::test]
3014    async fn mock_resolve_srv_via_dig_style_name() {
3015        let records = mock_zone_lookup(RecordType::SRV, "_sip._tcp.seer.test").await;
3016        assert_eq!(records.len(), 1);
3017        assert_eq!(records[0].name, "_sip._tcp.seer.test");
3018        match &records[0].data {
3019            RecordData::SRV {
3020                priority,
3021                weight,
3022                port,
3023                target,
3024            } => {
3025                assert_eq!((*priority, *weight, *port), (10, 5, 5060));
3026                assert_eq!(target, "sipserver.seer.test.");
3027            }
3028            other => panic!("expected SRV data, got {other:?}"),
3029        }
3030    }
3031
3032    #[tokio::test]
3033    async fn mock_resolve_ptr_transforms_ip_literal() {
3034        let records = mock_zone_lookup(RecordType::PTR, "192.0.2.1").await;
3035        assert_eq!(records.len(), 1);
3036        // The record is reported under the reverse-DNS name, not the raw IP.
3037        assert_eq!(records[0].name, "1.2.0.192.in-addr.arpa");
3038        assert!(matches!(
3039            &records[0].data,
3040            RecordData::PTR { target } if target == "ptr.seer.test."
3041        ));
3042    }
3043
3044    #[tokio::test]
3045    async fn mock_resolve_keeps_www_label() {
3046        // Regression: `resolve("www.x", CNAME)` queried `x` (normalize_domain
3047        // strips `www.`), which has no CNAME — an empty, wrong answer.
3048        let records = mock_zone_lookup(RecordType::CNAME, "www.seer.test").await;
3049        assert_eq!(records.len(), 1, "{records:?}");
3050        assert_eq!(records[0].name, "www.seer.test");
3051        assert_eq!(records[0].data.to_string(), "edge.cdn.test.");
3052    }
3053
3054    #[tokio::test]
3055    async fn mock_resolve_srv_keeps_www_label() {
3056        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
3057            ("_sip._tcp.www.seer.test", HickoryRecordType::SRV) => {
3058                MockReply::Answer(vec![HickoryRData::SRV(
3059                    hickory_resolver::proto::rr::rdata::SRV::new(
3060                        10,
3061                        5,
3062                        5060,
3063                        Name::from_ascii("sip.seer.test.").unwrap(),
3064                    ),
3065                )])
3066            }
3067            _ => MockReply::NoData,
3068        })
3069        .await;
3070        let records = mock_dns_resolver(port)
3071            .resolve(
3072                "_sip._tcp.www.seer.test",
3073                RecordType::SRV,
3074                Some("127.0.0.1"),
3075            )
3076            .await
3077            .expect("SRV against mock");
3078        assert_eq!(records.len(), 1, "{records:?}");
3079        assert_eq!(records[0].name, "_sip._tcp.www.seer.test");
3080    }
3081
3082    #[tokio::test]
3083    async fn mock_resolve_queries_a_wildcard_owner_name() {
3084        // The literal `*` label reaches the wire, and the records come back
3085        // reported under the wildcard name that was asked for.
3086        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
3087            ("*.seer.test", HickoryRecordType::A) => MockReply::Answer(vec![HickoryRData::A(
3088                hickory_resolver::proto::rr::rdata::A(Ipv4Addr::new(192, 0, 2, 42)),
3089            )]),
3090            _ => MockReply::NoData,
3091        })
3092        .await;
3093        let records = mock_dns_resolver(port)
3094            .resolve("*.seer.test", RecordType::A, Some("127.0.0.1"))
3095            .await
3096            .expect("wildcard A against mock");
3097        assert_eq!(records.len(), 1, "{records:?}");
3098        assert_eq!(records[0].name, "*.seer.test");
3099        assert_eq!(records[0].data.to_string(), "192.0.2.42");
3100    }
3101
3102    #[tokio::test]
3103    async fn mock_presence_still_probes_the_apex_for_www_input() {
3104        // `presence` answers a registration-level question, so it must keep
3105        // stripping `www.` even though `resolve` no longer does.
3106        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
3107            ("seer.test", HickoryRecordType::NS) => MockReply::Answer(vec![HickoryRData::NS(
3108                hickory_resolver::proto::rr::rdata::NS(Name::from_ascii("ns1.seer.test.").unwrap()),
3109            )]),
3110            // What a recursive resolver relays for a name inside the zone:
3111            // querying `www` itself would read as "no NS" → Absent.
3112            ("www.seer.test", HickoryRecordType::NS) => MockReply::NoDataWithSoa("seer.test"),
3113            _ => MockReply::NxDomain,
3114        })
3115        .await;
3116        let resolver = mock_dns_resolver_default(port);
3117        assert_eq!(
3118            resolver.presence("www.seer.test").await,
3119            DnsPresence::Present
3120        );
3121    }
3122
3123    #[tokio::test]
3124    async fn mock_resolve_ptr_transforms_ipv6_literal() {
3125        let records = mock_zone_lookup(RecordType::PTR, "2606:4700:4700::1111").await;
3126        assert_eq!(records.len(), 1, "{records:?}");
3127        assert_eq!(records[0].data.to_string(), "one.one.one.one.");
3128    }
3129
3130    #[tokio::test]
3131    async fn mock_resolve_any_aggregates_multiple_types() {
3132        let records = mock_zone_lookup(RecordType::ANY, "seer.test").await;
3133        // resolve_any fans out over ANY_TYPES; the zone publishes all of
3134        // these (and no CNAME or DS at the apex).
3135        for expected in [
3136            RecordType::A,
3137            RecordType::AAAA,
3138            RecordType::MX,
3139            RecordType::NS,
3140            RecordType::TXT,
3141            RecordType::SOA,
3142            RecordType::CAA,
3143            RecordType::HTTPS,
3144            RecordType::DNSKEY,
3145        ] {
3146            assert!(
3147                records.iter().any(|r| r.record_type == expected),
3148                "ANY must include {expected} records"
3149            );
3150        }
3151        // 2 A + 1 AAAA + 3 MX + 1 NS + 1 TXT + 1 SOA + 2 CAA + 1 HTTPS
3152        // + 1 DNSKEY = 13; every record is named by the query name.
3153        assert_eq!(records.len(), 13);
3154        assert!(records.iter().all(|r| r.name == "seer.test"));
3155        // CDS/CDNSKEY/SVCB are published but not part of the fan-out.
3156        assert!(!records.iter().any(|r| r.record_type == RecordType::CDS));
3157    }
3158
3159    #[tokio::test]
3160    async fn mock_resolve_converts_service_bindings() {
3161        let records = mock_zone_lookup(RecordType::HTTPS, "seer.test").await;
3162        assert_eq!(records.len(), 1, "{records:?}");
3163        let RecordData::HTTPS {
3164            priority,
3165            target,
3166            params,
3167        } = &records[0].data
3168        else {
3169            panic!("expected HTTPS data, got {:?}", records[0].data);
3170        };
3171        assert_eq!((*priority, target.as_str()), (1, "."));
3172        let pairs: Vec<(&str, &str)> = params
3173            .iter()
3174            .map(|p| (p.key.as_str(), p.value.as_str()))
3175            .collect();
3176        assert_eq!(
3177            pairs,
3178            [
3179                ("alpn", "h3,h2"),
3180                ("port", "8443"),
3181                ("ipv4hint", "192.0.2.1,192.0.2.2"),
3182                ("ech", "AAH+"),
3183                ("ipv6hint", "2001:db8::1"),
3184                // Private-use key: RFC 9460 `key<N>` name, the opaque value
3185                // as an escaped character-string (the space is `\032`).
3186                ("key65333", "ex\\0321"),
3187            ]
3188        );
3189        assert_eq!(
3190            records[0].data.to_string(),
3191            "1 . alpn=\"h3,h2\" port=8443 ipv4hint=192.0.2.1,192.0.2.2 ech=AAH+ \
3192             ipv6hint=2001:db8::1 key65333=ex\\0321"
3193        );
3194
3195        let alias = mock_zone_lookup(RecordType::HTTPS, "alias.seer.test").await;
3196        assert_eq!(alias.len(), 1, "{alias:?}");
3197        assert_eq!(alias[0].data.to_string(), "0 pool.seer.test.");
3198
3199        let svcb = mock_zone_lookup(RecordType::SVCB, "_8443._foo.seer.test").await;
3200        assert_eq!(svcb.len(), 1, "{svcb:?}");
3201        assert_eq!(svcb[0].record_type, RecordType::SVCB);
3202        assert_eq!(
3203            svcb[0].data.to_string(),
3204            "2 svc.seer.test. mandatory=alpn,port alpn=\"foo\" no-default-alpn port=8443"
3205        );
3206    }
3207
3208    #[tokio::test]
3209    async fn mock_resolve_converts_child_dnssec_records() {
3210        // ED25519 is algorithm 15; SHA-256 is digest type 2.
3211        let cds = mock_zone_lookup(RecordType::CDS, "seer.test").await;
3212        let shown: Vec<String> = cds.iter().map(|r| r.data.to_string()).collect();
3213        assert_eq!(shown, ["2371 15 2 ABCDEF", "0 0 0 00"]);
3214        assert!(cds.iter().all(|r| r.record_type == RecordType::CDS));
3215
3216        let cdnskey = mock_zone_lookup(RecordType::CDNSKEY, "seer.test").await;
3217        let shown: Vec<String> = cdnskey.iter().map(|r| r.data.to_string()).collect();
3218        assert_eq!(
3219            shown,
3220            [
3221                "257 3 15 BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
3222                // The RFC 8078 delete request keeps its one-octet key.
3223                "0 3 0 AA==",
3224            ]
3225        );
3226    }
3227
3228    #[tokio::test]
3229    async fn mock_resolve_keeps_only_the_requested_type_behind_a_cname() {
3230        // `resolve`'s contract is unchanged by the CNAME chain `query`
3231        // reports: records of the requested type only, each named by the
3232        // query name.
3233        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
3234            ("www.seer.test", HickoryRecordType::A) => MockReply::Records(vec![
3235                record("www.seer.test.", 300, cname_rdata("edge.cdn.test.")),
3236                record("edge.cdn.test.", 60, a_rdata([192, 0, 2, 7])),
3237            ]),
3238            _ => MockReply::NxDomain,
3239        })
3240        .await;
3241        let records = mock_dns_resolver(port)
3242            .resolve("www.seer.test", RecordType::A, Some("127.0.0.1"))
3243            .await
3244            .expect("A behind a CNAME");
3245        assert_eq!(
3246            records,
3247            [DnsRecord {
3248                name: "www.seer.test".to_string(),
3249                record_type: RecordType::A,
3250                ttl: 60,
3251                data: RecordData::A {
3252                    address: "192.0.2.7".to_string(),
3253                },
3254            }]
3255        );
3256    }
3257
3258    // --- query(): the full dig-style result -----------------------------
3259
3260    fn is_probe(qname: &str) -> bool {
3261        qname.starts_with("seer-probe-")
3262    }
3263
3264    /// Every (name, type) the mock was asked, in order.
3265    type Asked = Arc<std::sync::Mutex<Vec<(String, HickoryRecordType)>>>;
3266
3267    /// [`spawn_mock_dns_fn`] that also records what it was asked.
3268    async fn spawn_recording<F>(mut handler: F) -> (u16, Asked)
3269    where
3270        F: FnMut(&str, HickoryRecordType) -> MockReply + Send + 'static,
3271    {
3272        let asked: Asked = Arc::default();
3273        let log = Arc::clone(&asked);
3274        let port = spawn_mock_dns_fn(move |qname, qtype| {
3275            log.lock().unwrap().push((qname.to_string(), qtype));
3276            handler(qname, qtype)
3277        })
3278        .await;
3279        (port, asked)
3280    }
3281
3282    fn probes_sent(asked: &Asked) -> usize {
3283        asked
3284            .lock()
3285            .unwrap()
3286            .iter()
3287            .filter(|(name, _)| is_probe(name))
3288            .count()
3289    }
3290
3291    async fn mock_query(port: u16, name: &str, record_type: RecordType) -> DnsQueryResult {
3292        mock_dns_resolver(port)
3293            .query(name, record_type, Some("127.0.0.1"))
3294            .await
3295            .unwrap_or_else(|e| panic!("{record_type} query against mock must succeed: {e}"))
3296    }
3297
3298    fn shown(records: &[&DnsRecord]) -> Vec<(String, RecordType, String)> {
3299        records
3300            .iter()
3301            .map(|r| (r.name.clone(), r.record_type, r.data.to_string()))
3302            .collect()
3303    }
3304
3305    fn row(name: &str, record_type: RecordType, data: &str) -> (String, RecordType, String) {
3306        (name.to_string(), record_type, data.to_string())
3307    }
3308
3309    #[tokio::test]
3310    async fn mock_query_reports_the_cname_chain_under_real_owners() {
3311        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
3312            ("www.seer.test", HickoryRecordType::A) => MockReply::Records(vec![
3313                record("www.seer.test.", 300, cname_rdata("edge.cdn.test.")),
3314                record("edge.cdn.test.", 120, cname_rdata("origin.cdn.test.")),
3315                record("origin.cdn.test.", 60, a_rdata([192, 0, 2, 7])),
3316                record("origin.cdn.test.", 60, a_rdata([192, 0, 2, 8])),
3317            ]),
3318            _ => MockReply::NxDomain,
3319        })
3320        .await;
3321        let result = mock_query(port, "WWW.Seer.test", RecordType::A).await;
3322
3323        assert_eq!(result.name, "www.seer.test");
3324        assert_eq!(result.record_type, RecordType::A);
3325        assert_eq!(result.server.as_deref(), Some("127.0.0.1"));
3326        assert_eq!(result.status, DnsStatus::NoError);
3327        assert_eq!(result.flags, ["qr", "rd", "ra"]);
3328        assert_eq!(
3329            shown(&result.cname_chain().collect::<Vec<_>>()),
3330            [
3331                row("www.seer.test", RecordType::CNAME, "edge.cdn.test."),
3332                row("edge.cdn.test", RecordType::CNAME, "origin.cdn.test."),
3333            ]
3334        );
3335        assert_eq!(
3336            shown(&result.records().collect::<Vec<_>>()),
3337            [
3338                row("origin.cdn.test", RecordType::A, "192.0.2.7"),
3339                row("origin.cdn.test", RecordType::A, "192.0.2.8"),
3340            ]
3341        );
3342        assert_eq!(result.answers[2].ttl, 60);
3343        assert!(result.authority.is_empty());
3344        assert!(!result.is_nodata());
3345        // The sibling probe got NXDOMAIN: no wildcard.
3346        let wildcard = result
3347            .wildcard
3348            .expect("probe ran for a name below seer.test");
3349        assert!(is_probe(&wildcard.probe_name), "{wildcard:?}");
3350        assert!(wildcard.probe_name.ends_with(".seer.test"), "{wildcard:?}");
3351        assert!(!wildcard.present && !wildcard.matches_answer);
3352    }
3353
3354    #[tokio::test]
3355    async fn mock_query_reports_idn_owners_as_a_labels() {
3356        // The query name is normalized to its A-label; owners and the CNAME
3357        // target are reported in that spelling too, so the chain connects
3358        // name for name.
3359        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
3360            ("xn--bcher-kva.seer.test", HickoryRecordType::A) => MockReply::Records(vec![
3361                record(
3362                    "xn--bcher-kva.seer.test.",
3363                    300,
3364                    cname_rdata("edge.xn--caf-dma.test."),
3365                ),
3366                record("edge.xn--caf-dma.test.", 60, a_rdata([192, 0, 2, 7])),
3367            ]),
3368            _ => MockReply::NxDomain,
3369        })
3370        .await;
3371        let result = mock_query(port, "Bücher.seer.test", RecordType::A).await;
3372        assert_eq!(result.name, "xn--bcher-kva.seer.test");
3373        assert_eq!(
3374            shown(&result.answers.iter().collect::<Vec<_>>()),
3375            [
3376                row(
3377                    "xn--bcher-kva.seer.test",
3378                    RecordType::CNAME,
3379                    "edge.xn--caf-dma.test."
3380                ),
3381                row("edge.xn--caf-dma.test", RecordType::A, "192.0.2.7"),
3382            ]
3383        );
3384        assert_eq!(result.cname_chain().count(), 1);
3385    }
3386
3387    #[tokio::test]
3388    async fn query_refuses_a_reserved_nameserver() {
3389        // SSRF: `query` builds its upstream through the same vetted path as
3390        // `resolve`, so a loopback or private nameserver is refused before
3391        // any packet is sent.
3392        let r = DnsResolver::new();
3393        for reserved in ["127.0.0.1", "10.0.0.1", "::1", "tls://192.168.1.1"] {
3394            let err = r
3395                .query("www.example.com", RecordType::A, Some(reserved))
3396                .await
3397                .expect_err("a reserved nameserver must be refused");
3398            let msg = err.to_string().to_lowercase();
3399            assert!(
3400                msg.contains("blocked") || msg.contains("reserved"),
3401                "{reserved}: {msg}"
3402            );
3403        }
3404    }
3405
3406    #[tokio::test]
3407    async fn mock_query_reports_a_cname_the_server_did_not_follow_as_sent() {
3408        // A server that does not recurse answers with the CNAME alone. Like
3409        // dig, `query` reports that response and asks nothing more — hickory's
3410        // resolver used to chase the target itself, with a second query to
3411        // the same server.
3412        let (port, asked) = spawn_recording(|qname, qtype| match (qname, qtype) {
3413            ("www.seer.test", HickoryRecordType::A) => MockReply::Records(vec![record(
3414                "www.seer.test.",
3415                300,
3416                cname_rdata("edge.cdn.test."),
3417            )]),
3418            ("edge.cdn.test", HickoryRecordType::A) => {
3419                MockReply::Answer(vec![a_rdata([192, 0, 2, 7])])
3420            }
3421            _ => MockReply::NxDomain,
3422        })
3423        .await;
3424        let result = mock_query(port, "www.seer.test", RecordType::A).await;
3425        assert_eq!(result.status, DnsStatus::NoError);
3426        assert_eq!(result.flags, ["qr", "rd", "ra"]);
3427        assert_eq!(
3428            shown(&result.answers.iter().collect::<Vec<_>>()),
3429            [row("www.seer.test", RecordType::CNAME, "edge.cdn.test.")]
3430        );
3431        assert_eq!(result.records().count(), 0);
3432        assert!(
3433            !asked
3434                .lock()
3435                .unwrap()
3436                .iter()
3437                .any(|(n, _)| n == "edge.cdn.test"),
3438            "the target is not chased: {asked:?}"
3439        );
3440    }
3441
3442    #[tokio::test]
3443    async fn mock_query_reports_a_dangling_cname_with_its_chain() {
3444        // Regression: a CNAME whose target does not exist read as a bare
3445        // "Name does not exist (NXDOMAIN)" — no chain, no flags — because
3446        // hickory's resolver discarded the chain and the response header.
3447        let port = spawn_mock_dns_fn(|qname, _| match qname {
3448            "www.seer.test" => MockReply::Negative {
3449                code: ResponseCode::NXDomain,
3450                chain: vec![
3451                    record("www.seer.test.", 300, cname_rdata("shop.seer.test.")),
3452                    record("shop.seer.test.", 300, cname_rdata("gone.cdn.test.")),
3453                ],
3454                soa: "cdn.test",
3455            },
3456            _ => MockReply::NxDomainWithSoa("seer.test"),
3457        })
3458        .await;
3459        let result = mock_query(port, "www.seer.test", RecordType::A).await;
3460        assert_eq!(result.status, DnsStatus::NxDomain);
3461        assert_eq!(result.flags, ["qr", "rd", "ra"]);
3462        assert_eq!(
3463            shown(&result.cname_chain().collect::<Vec<_>>()),
3464            [
3465                row("www.seer.test", RecordType::CNAME, "shop.seer.test."),
3466                row("shop.seer.test", RecordType::CNAME, "gone.cdn.test."),
3467            ]
3468        );
3469        assert_eq!(result.records().count(), 0);
3470        assert_eq!(
3471            shown(&result.authority.iter().collect::<Vec<_>>()),
3472            [row(
3473                "cdn.test",
3474                RecordType::SOA,
3475                "ns1.cdn.test. hostmaster.cdn.test. 2026070101 7200 3600 1209600 300"
3476            )]
3477        );
3478        assert!(!result.is_nodata());
3479        assert_eq!(result.referral_zone(), None);
3480        assert_eq!(
3481            result.wildcard, None,
3482            "a negative answer has nothing to compare"
3483        );
3484    }
3485
3486    #[tokio::test]
3487    async fn mock_query_reports_nodata_behind_a_cname() {
3488        let port = spawn_mock_dns_fn(|qname, _| match qname {
3489            "www.seer.test" => MockReply::Negative {
3490                code: ResponseCode::NoError,
3491                chain: vec![record("www.seer.test.", 300, cname_rdata("edge.cdn.test."))],
3492                soa: "cdn.test",
3493            },
3494            _ => MockReply::NxDomain,
3495        })
3496        .await;
3497        let result = mock_query(port, "www.seer.test", RecordType::AAAA).await;
3498        assert_eq!(result.status, DnsStatus::NoError);
3499        assert_eq!(result.flags, ["qr", "rd", "ra"]);
3500        assert_eq!(result.cname_chain().count(), 1);
3501        assert_eq!(result.records().count(), 0);
3502        assert!(result.is_nodata());
3503        assert_eq!(result.referral_zone(), None);
3504        assert_eq!(result.authority.len(), 1);
3505        assert_eq!(result.authority[0].name, "cdn.test");
3506    }
3507
3508    #[tokio::test]
3509    async fn mock_query_reports_nxdomain_with_the_zone_soa() {
3510        let (port, asked) = spawn_recording(|_, _| MockReply::NxDomainWithSoa("seer.test")).await;
3511        let result = mock_query(port, "nx.seer.test", RecordType::A).await;
3512        assert_eq!(result.status, DnsStatus::NxDomain);
3513        assert!(result.answers.is_empty());
3514        assert!(!result.is_nodata(), "NXDOMAIN is not NODATA");
3515        // Regression: a negative answer came back without its header.
3516        assert_eq!(result.flags, ["qr", "rd", "ra"]);
3517        assert_eq!(
3518            shown(&result.authority.iter().collect::<Vec<_>>()),
3519            [row(
3520                "seer.test",
3521                RecordType::SOA,
3522                "ns1.seer.test. hostmaster.seer.test. 2026070101 7200 3600 1209600 300"
3523            )]
3524        );
3525        // A negative answer has nothing to compare a wildcard with.
3526        assert_eq!(result.wildcard, None);
3527        assert!(asked
3528            .lock()
3529            .unwrap()
3530            .iter()
3531            .any(|(n, _)| n == "nx.seer.test"));
3532    }
3533
3534    #[tokio::test]
3535    async fn mock_query_reports_nodata_with_the_zone_soa() {
3536        let port = spawn_mock_dns_fn(|_, _| MockReply::NoDataWithSoa("seer.test")).await;
3537        let result = mock_query(port, "www.seer.test", RecordType::MX).await;
3538        assert_eq!(result.status, DnsStatus::NoError);
3539        assert!(result.is_nodata());
3540        assert!(result.answers.is_empty());
3541        assert_eq!(result.authority.len(), 1);
3542        assert_eq!(result.authority[0].record_type, RecordType::SOA);
3543        assert_eq!(result.authority[0].name, "seer.test");
3544        assert_eq!(result.wildcard, None);
3545    }
3546
3547    #[tokio::test]
3548    async fn mock_query_reports_error_codes_as_results() {
3549        let port = spawn_mock_dns_fn(|qname, _| match qname {
3550            "servfail.seer.test" => MockReply::Rcode(ResponseCode::ServFail),
3551            "refused.seer.test" => MockReply::Rcode(ResponseCode::Refused),
3552            "notimp.seer.test" => MockReply::Rcode(ResponseCode::NotImp),
3553            _ => MockReply::NxDomain,
3554        })
3555        .await;
3556        for (name, status, shown) in [
3557            ("servfail.seer.test", DnsStatus::ServFail, "SERVFAIL"),
3558            ("refused.seer.test", DnsStatus::Refused, "REFUSED"),
3559            ("notimp.seer.test", DnsStatus::Other(4), "NOTIMP"),
3560        ] {
3561            let result = mock_query(port, name, RecordType::A).await;
3562            assert_eq!(result.status, status, "{name}");
3563            assert_eq!(result.status.to_string(), shown);
3564            assert!(result.answers.is_empty() && result.authority.is_empty());
3565            assert_eq!(result.flags, ["qr", "rd", "ra"], "{name}");
3566            assert!(!result.is_nodata());
3567            assert_eq!(result.wildcard, None);
3568        }
3569    }
3570
3571    #[tokio::test]
3572    async fn mock_query_reads_an_extended_rcode_from_edns() {
3573        // BADVERS (16) does not fit the header's four bits: its high bits
3574        // travel in the OPT record, and the status must include them.
3575        let port = spawn_mock_dns_fn(|_, _| MockReply::Rcode(ResponseCode::BADVERS)).await;
3576        let result = mock_query(port, "www.seer.test", RecordType::A).await;
3577        assert_eq!(result.status, DnsStatus::Other(16));
3578        assert_eq!(result.status.to_string(), "BADVERS");
3579    }
3580
3581    #[tokio::test]
3582    async fn mock_query_repeats_a_truncated_reply_over_tcp() {
3583        // A bare `@server` spec configures UDP only; like dig, a truncated
3584        // reply is asked again over TCP, of the same address and port.
3585        let transports = Arc::new(std::sync::Mutex::new(0));
3586        let seen = Arc::clone(&transports);
3587        let port = spawn_mock_dns_fn_with_tcp(move |qname, _| {
3588            if qname != "big.seer.test" {
3589                return MockReply::NxDomain;
3590            }
3591            let mut n = seen.lock().unwrap();
3592            *n += 1;
3593            if *n == 1 {
3594                MockReply::Truncated
3595            } else {
3596                MockReply::Answer((1..=3).map(|i| a_rdata([192, 0, 2, i])).collect())
3597            }
3598        })
3599        .await;
3600        let result = mock_query(port, "big.seer.test", RecordType::A).await;
3601        assert_eq!(result.status, DnsStatus::NoError);
3602        assert_eq!(result.records().count(), 3);
3603        assert!(
3604            !result.flags.contains(&"tc".to_string()),
3605            "{:?}",
3606            result.flags
3607        );
3608        assert_eq!(*transports.lock().unwrap(), 2, "UDP, then TCP");
3609    }
3610
3611    /// Two mock servers on loopback whose answer for `big.seer.test` is too
3612    /// big for UDP: every UDP reply is truncated, TCP returns three A
3613    /// records. One upstream group, so hickory queries both in parallel.
3614    async fn two_truncating_servers() -> ResolverConfig {
3615        let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
3616        for _ in 0..2 {
3617            let port = spawn_mock_dns_split(
3618                |_, _| MockReply::Truncated,
3619                |qname, _| {
3620                    if qname == "big.seer.test" {
3621                        MockReply::Answer((1..=3).map(|i| a_rdata([192, 0, 2, i])).collect())
3622                    } else {
3623                        MockReply::NxDomain
3624                    }
3625                },
3626            )
3627            .await;
3628            for server in
3629                single_server_config(IpAddr::from([127, 0, 0, 1]), port, true).name_servers()
3630            {
3631                config.add_name_server(server.clone());
3632            }
3633        }
3634        config
3635    }
3636
3637    /// Regression: an answer too big for UDP (cloudflare.com's apex TXT) was
3638    /// reported as a failed lookup, so `posture` read SPF as unknown.
3639    #[tokio::test]
3640    async fn mock_resolve_asks_over_tcp_when_parallel_udp_replies_truncate() {
3641        let r = DnsResolver::new().with_timeout(Duration::from_secs(2));
3642        let config = two_truncating_servers().await;
3643        let target = ResolveTarget {
3644            resolver: Cow::Owned(build_resolver(config.clone(), r.timeout).unwrap()),
3645            config,
3646        };
3647        let records = r
3648            .resolve_records(&target, "big.seer.test", RecordType::A)
3649            .await
3650            .expect("a truncated answer is asked again over TCP");
3651        assert_eq!(records.len(), 3);
3652    }
3653
3654    /// The hickory-resolver 0.26.3 race the TCP fallback exists for. When
3655    /// this fails, hickory asks over TCP by itself: remove
3656    /// `lookup_with_tcp_fallback`.
3657    #[tokio::test]
3658    async fn hickory_still_abandons_truncated_parallel_udp_replies() {
3659        let config = two_truncating_servers().await;
3660        let resolver = build_resolver(config, Duration::from_secs(2)).unwrap();
3661        let result = resolver.lookup("big.seer.test", HickoryRecordType::A).await;
3662        assert!(
3663            matches!(result, Err(NetError::Truncated)),
3664            "{:?}",
3665            result.map(|lookup| lookup.answers().len())
3666        );
3667    }
3668
3669    #[tokio::test]
3670    async fn mock_query_reports_a_referral_as_neither_nodata_nor_an_answer() {
3671        // A server that is not authoritative for the name and does not
3672        // recurse refers the query to the child zone's servers. Regression:
3673        // this was reported as NODATA ("the name exists").
3674        let port = spawn_mock_dns_fn(|_, _| MockReply::Delegation {
3675            zone: "child.seer.test".to_string(),
3676            servers: vec![
3677                (
3678                    "ns1.child.seer.test".to_string(),
3679                    vec![IpAddr::from([192, 0, 2, 1])],
3680                ),
3681                ("ns2.child.seer.test".to_string(), vec![]),
3682            ],
3683        })
3684        .await;
3685        let result = mock_query(port, "www.child.seer.test", RecordType::A).await;
3686        assert_eq!(result.status, DnsStatus::NoError);
3687        // The header, as the referring server set it: no `aa`, no `ra`.
3688        assert_eq!(result.flags, ["qr", "rd"]);
3689        assert!(result.answers.is_empty());
3690        assert_eq!(result.referral_zone(), Some("child.seer.test"));
3691        assert!(!result.is_nodata());
3692        assert_eq!(
3693            shown(&result.authority.iter().collect::<Vec<_>>()),
3694            [
3695                row("child.seer.test", RecordType::NS, "ns1.child.seer.test."),
3696                row("child.seer.test", RecordType::NS, "ns2.child.seer.test."),
3697            ]
3698        );
3699        assert_eq!(result.wildcard, None);
3700    }
3701
3702    #[tokio::test]
3703    async fn mock_query_reports_special_use_names_as_answered_locally() {
3704        // hickory answers RFC 6761 names itself. Regression: `-x 127.0.0.1`
3705        // was reported as the server's answer (`flags: qr`, `server: …`) and,
3706        // since the probe's sibling got the same local answer, as likely
3707        // wildcard-synthesized.
3708        let (port, asked) = spawn_recording(|_, _| MockReply::NxDomain).await;
3709        let ptr = mock_query(port, "127.0.0.1", RecordType::PTR).await;
3710        assert_eq!(ptr.name, "1.0.0.127.in-addr.arpa");
3711        assert!(ptr.answered_locally);
3712        assert_eq!(ptr.server, None, "no server was asked");
3713        assert!(ptr.flags.is_empty(), "the header is hickory's own");
3714        assert_eq!(ptr.status, DnsStatus::NoError);
3715        assert_eq!(
3716            shown(&ptr.answers.iter().collect::<Vec<_>>()),
3717            [row("1.0.0.127.in-addr.arpa", RecordType::PTR, "localhost.")]
3718        );
3719        assert_eq!(ptr.wildcard, None, "no wildcard probe for a local name");
3720
3721        let mx = mock_query(port, "foo.localhost", RecordType::MX).await;
3722        assert!(mx.answered_locally && mx.is_nodata());
3723        let onion = mock_query(port, "x.onion", RecordType::A).await;
3724        assert!(onion.answered_locally);
3725        assert_eq!(onion.status, DnsStatus::NxDomain);
3726
3727        assert!(asked.lock().unwrap().is_empty(), "{asked:?}");
3728    }
3729
3730    #[tokio::test]
3731    async fn mock_query_times_out_as_an_error() {
3732        let port = spawn_mock_dns(MockMode::Ignore).await;
3733        let resolver = DnsResolver::new()
3734            .with_timeout(Duration::from_millis(200))
3735            .allowing_private_hosts()
3736            .with_port(port);
3737        let result = resolver
3738            .query("www.seer.test", RecordType::A, Some("127.0.0.1"))
3739            .await;
3740        assert!(
3741            matches!(&result, Err(SeerError::DnsError(m)) if m.contains("A lookup failed")),
3742            "an unanswered query is a transport error, got: {result:?}"
3743        );
3744    }
3745
3746    #[tokio::test]
3747    async fn mock_query_reads_flags_from_the_response_header() {
3748        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
3749            ("seer.test", HickoryRecordType::A) => {
3750                MockReply::AuthoritativeAnswer(vec![a_rdata([192, 0, 2, 1])])
3751            }
3752            _ => MockReply::NxDomain,
3753        })
3754        .await;
3755        let result = mock_query(port, "seer.test", RecordType::A).await;
3756        assert_eq!(result.flags, ["qr", "aa", "rd", "ra"]);
3757        assert_eq!(result.cname_chain().count(), 0);
3758        assert_eq!(
3759            result.records().next().map(|r| r.name.as_str()),
3760            Some("seer.test")
3761        );
3762    }
3763
3764    #[tokio::test]
3765    async fn mock_query_converts_the_new_record_types_under_their_owner() {
3766        let port = spawn_mock_dns(MockMode::Zone).await;
3767        let https = mock_query(port, "seer.test", RecordType::HTTPS).await;
3768        assert_eq!(https.answers.len(), 1, "{https:?}");
3769        assert_eq!(https.answers[0].name, "seer.test");
3770        assert!(https.answers[0]
3771            .data
3772            .to_string()
3773            .starts_with("1 . alpn=\"h3,h2\" port=8443"));
3774
3775        let alias = mock_query(port, "alias.seer.test", RecordType::HTTPS).await;
3776        assert_eq!(alias.answers[0].data.to_string(), "0 pool.seer.test.");
3777        assert_eq!(alias.answers[0].name, "alias.seer.test");
3778
3779        let cds = mock_query(port, "seer.test", RecordType::CDS).await;
3780        assert_eq!(cds.answers.len(), 2);
3781        let cdnskey = mock_query(port, "seer.test", RecordType::CDNSKEY).await;
3782        assert_eq!(cdnskey.answers[1].data.to_string(), "0 3 0 AA==");
3783    }
3784
3785    #[tokio::test]
3786    async fn mock_query_sorts_mx_like_resolve() {
3787        let port = spawn_mock_dns(MockMode::Zone).await;
3788        let result = mock_query(port, "seer.test", RecordType::MX).await;
3789        let prefs: Vec<String> = result.answers.iter().map(|r| r.data.to_string()).collect();
3790        assert_eq!(
3791            prefs,
3792            [
3793                "10 a.mail.seer.test.",
3794                "20 b.mail.seer.test.",
3795                "30 c.mail.seer.test."
3796            ]
3797        );
3798    }
3799
3800    #[tokio::test]
3801    async fn mock_query_resolves_srv_and_ptr_names_like_resolve() {
3802        let (port, asked) = spawn_recording(|qname, qtype| match (qname, qtype) {
3803            ("_sip._tcp.seer.test", HickoryRecordType::SRV) => {
3804                MockReply::Answer(vec![HickoryRData::SRV(
3805                    hickory_resolver::proto::rr::rdata::SRV::new(
3806                        10,
3807                        5,
3808                        5060,
3809                        Name::from_ascii("sip.seer.test.").unwrap(),
3810                    ),
3811                )])
3812            }
3813            ("1.2.0.192.in-addr.arpa", HickoryRecordType::PTR) => {
3814                MockReply::Answer(vec![HickoryRData::PTR(
3815                    hickory_resolver::proto::rr::rdata::PTR(
3816                        Name::from_ascii("ptr.seer.test.").unwrap(),
3817                    ),
3818                )])
3819            }
3820            _ => MockReply::NxDomain,
3821        })
3822        .await;
3823
3824        let srv = mock_query(port, "_sip._tcp.seer.test", RecordType::SRV).await;
3825        assert_eq!(
3826            shown(&srv.answers.iter().collect::<Vec<_>>()),
3827            [row(
3828                "_sip._tcp.seer.test",
3829                RecordType::SRV,
3830                "10 5 5060 sip.seer.test."
3831            )]
3832        );
3833        // The sibling of an underscore name replaces its leftmost label.
3834        let probe = srv.wildcard.expect("SRV name is below seer.test");
3835        assert!(probe.probe_name.ends_with("._tcp.seer.test"), "{probe:?}");
3836        assert!(asked.lock().unwrap().iter().any(|(n, t)| is_probe(n)
3837            && n.ends_with("._tcp.seer.test")
3838            && *t == HickoryRecordType::SRV));
3839
3840        let ptr = mock_query(port, "192.0.2.1", RecordType::PTR).await;
3841        assert_eq!(ptr.name, "1.2.0.192.in-addr.arpa");
3842        assert_eq!(ptr.answers[0].name, "1.2.0.192.in-addr.arpa");
3843
3844        let err = mock_dns_resolver(port)
3845            .query("seer.test", RecordType::SRV, Some("127.0.0.1"))
3846            .await
3847            .expect_err("a bare domain is not an SRV query");
3848        assert!(matches!(err, SeerError::InvalidInput(_)), "{err:?}");
3849        let err = mock_dns_resolver(port)
3850            .query(".bad.example", RecordType::A, Some("127.0.0.1"))
3851            .await;
3852        assert!(err.is_err(), "invalid names are rejected before any I/O");
3853    }
3854
3855    #[tokio::test]
3856    async fn mock_query_any_fans_out_to_the_new_types_and_dedupes() {
3857        let (port, asked) = spawn_recording(|qname, qtype| {
3858            let chain = || record("www.seer.test.", 300, cname_rdata("edge.seer.test."));
3859            match (qname, qtype) {
3860                ("www.seer.test", HickoryRecordType::CNAME) => MockReply::Records(vec![chain()]),
3861                ("www.seer.test", HickoryRecordType::A) => MockReply::Records(vec![
3862                    chain(),
3863                    record("edge.seer.test.", 60, a_rdata([192, 0, 2, 7])),
3864                ]),
3865                ("www.seer.test", HickoryRecordType::HTTPS) => {
3866                    MockReply::Records(vec![
3867                        chain(),
3868                        record(
3869                            "edge.seer.test.",
3870                            60,
3871                            HickoryRData::HTTPS(hickory_resolver::proto::rr::rdata::HTTPS(
3872                                SVCB::new(1, Name::root(), vec![]),
3873                            )),
3874                        ),
3875                    ])
3876                }
3877                // Every other type: the chain alone, as a NODATA at the
3878                // target reads.
3879                ("www.seer.test", _) => MockReply::Records(vec![chain()]),
3880                _ => MockReply::NoDataWithSoa("seer.test"),
3881            }
3882        })
3883        .await;
3884        let result = mock_query(port, "www.seer.test", RecordType::ANY).await;
3885
3886        assert_eq!(result.record_type, RecordType::ANY);
3887        assert_eq!(result.status, DnsStatus::NoError);
3888        assert_eq!(result.flags, ["qr", "rd", "ra"]);
3889        // The CNAME came back from all 11 sub-queries; it is listed once.
3890        assert_eq!(
3891            shown(&result.answers.iter().collect::<Vec<_>>()),
3892            [
3893                row("www.seer.test", RecordType::CNAME, "edge.seer.test."),
3894                row("edge.seer.test", RecordType::A, "192.0.2.7"),
3895                row("edge.seer.test", RecordType::HTTPS, "1 ."),
3896            ]
3897        );
3898        assert_eq!(result.cname_chain().count(), 1);
3899        assert_eq!(result.records().count(), 2);
3900        assert!(!result.is_nodata());
3901        assert_eq!(result.wildcard, None, "ANY is never probed");
3902
3903        let asked: Vec<HickoryRecordType> = asked
3904            .lock()
3905            .unwrap()
3906            .iter()
3907            .filter(|(n, _)| n == "www.seer.test")
3908            .map(|(_, t)| *t)
3909            .collect();
3910        for wire in ANY_TYPES.iter().filter_map(|t| wire_type(*t)) {
3911            assert!(asked.contains(&wire), "ANY must query {wire}: {asked:?}");
3912        }
3913        assert!(
3914            !asked.contains(&HickoryRecordType::ANY),
3915            "ANY is never sent"
3916        );
3917    }
3918
3919    #[tokio::test]
3920    async fn mock_query_any_errors_only_when_every_sub_query_fails() {
3921        let port = spawn_mock_dns(MockMode::Ignore).await;
3922        let resolver = DnsResolver::new()
3923            .with_timeout(Duration::from_millis(200))
3924            .allowing_private_hosts()
3925            .with_port(port);
3926        let result = resolver
3927            .query("www.seer.test", RecordType::ANY, Some("127.0.0.1"))
3928            .await;
3929        assert!(matches!(result, Err(SeerError::DnsError(_))), "{result:?}");
3930    }
3931
3932    /// A zone where `www` has its own A record and every other name under
3933    /// `seer.test` answers the probe with `probe_reply`.
3934    async fn wildcard_zone(probe_reply: fn() -> MockReply) -> (u16, Asked) {
3935        spawn_recording(move |qname, qtype| match (qname, qtype) {
3936            ("www.seer.test", HickoryRecordType::A) => {
3937                MockReply::Answer(vec![a_rdata([192, 0, 2, 50])])
3938            }
3939            (name, HickoryRecordType::A) if is_probe(name) => probe_reply(),
3940            _ => MockReply::NxDomain,
3941        })
3942        .await
3943    }
3944
3945    #[tokio::test]
3946    async fn mock_wildcard_probe_matching_the_answer() {
3947        let (port, asked) =
3948            wildcard_zone(|| MockReply::Answer(vec![a_rdata([192, 0, 2, 50])])).await;
3949        let result = mock_query(port, "www.seer.test", RecordType::A).await;
3950        let probe = result.wildcard.expect("probe attached");
3951        assert!(probe.present && probe.matches_answer, "{probe:?}");
3952        assert_eq!(probes_sent(&asked), 1);
3953    }
3954
3955    #[tokio::test]
3956    async fn mock_wildcard_probe_with_different_data() {
3957        let (port, _) = wildcard_zone(|| MockReply::Answer(vec![a_rdata([192, 0, 2, 99])])).await;
3958        let result = mock_query(port, "www.seer.test", RecordType::A).await;
3959        let probe = result.wildcard.expect("probe attached");
3960        assert!(probe.present && !probe.matches_answer, "{probe:?}");
3961    }
3962
3963    #[tokio::test]
3964    async fn mock_wildcard_probe_absent() {
3965        let (port, _) = wildcard_zone(|| MockReply::NxDomainWithSoa("seer.test")).await;
3966        let result = mock_query(port, "www.seer.test", RecordType::A).await;
3967        let probe = result.wildcard.expect("probe attached");
3968        assert!(!probe.present && !probe.matches_answer, "{probe:?}");
3969    }
3970
3971    #[tokio::test]
3972    async fn mock_wildcard_probe_that_fails_is_dropped_not_fatal() {
3973        let (port, _) = wildcard_zone(|| MockReply::Rcode(ResponseCode::ServFail)).await;
3974        let result = mock_query(port, "www.seer.test", RecordType::A).await;
3975        assert_eq!(result.status, DnsStatus::NoError);
3976        assert_eq!(result.wildcard, None);
3977
3978        // An unanswered probe times out; the main answer still stands.
3979        let (port, _) = wildcard_zone(|| MockReply::NoReply).await;
3980        let resolver = DnsResolver::new()
3981            .with_timeout(Duration::from_millis(200))
3982            .allowing_private_hosts()
3983            .with_port(port);
3984        let result = resolver
3985            .query("www.seer.test", RecordType::A, Some("127.0.0.1"))
3986            .await
3987            .expect("the probe never fails the query");
3988        assert_eq!(result.records().count(), 1);
3989        assert_eq!(result.wildcard, None);
3990    }
3991
3992    #[tokio::test]
3993    async fn mock_wildcard_probe_is_not_sent_for_ineligible_names() {
3994        // Every name answers, so a probe, if sent, would find a "wildcard".
3995        let (port, asked) =
3996            spawn_recording(|_, _| MockReply::Answer(vec![a_rdata([192, 0, 2, 50])])).await;
3997        // The registrable domain itself: its sibling would be TLD-level.
3998        assert_eq!(
3999            mock_query(port, "seer.test", RecordType::A).await.wildcard,
4000            None
4001        );
4002        // The wildcard owner itself.
4003        assert_eq!(
4004            mock_query(port, "*.seer.test", RecordType::A)
4005                .await
4006                .wildcard,
4007            None
4008        );
4009        // ANY is a fan-out, not one answer.
4010        assert_eq!(
4011            mock_query(port, "www.seer.test", RecordType::ANY)
4012                .await
4013                .wildcard,
4014            None
4015        );
4016        assert_eq!(probes_sent(&asked), 0, "{:?}", asked.lock().unwrap());
4017    }
4018
4019    #[tokio::test]
4020    async fn mock_nodata_folds_to_empty_and_classifies_absent() {
4021        let port = spawn_mock_dns(MockMode::NoData).await;
4022        let result = mock_dns_resolver(port)
4023            .resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
4024            .await;
4025        assert!(
4026            matches!(&result, Ok(records) if records.is_empty()),
4027            "NODATA must fold to Ok(vec![]), got: {result:?}"
4028        );
4029        assert_eq!(classify_ns_presence(&result), DnsPresence::Absent);
4030    }
4031
4032    #[tokio::test]
4033    async fn mock_nxdomain_folds_to_empty_and_classifies_absent() {
4034        let port = spawn_mock_dns(MockMode::Nxdomain).await;
4035        let result = mock_dns_resolver(port)
4036            .resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
4037            .await;
4038        assert!(
4039            matches!(&result, Ok(records) if records.is_empty()),
4040            "NXDOMAIN must fold to Ok(vec![]), got: {result:?}"
4041        );
4042        assert_eq!(classify_ns_presence(&result), DnsPresence::Absent);
4043    }
4044
4045    #[tokio::test]
4046    async fn mock_timeout_errors_and_classifies_unknown() {
4047        let port = spawn_mock_dns(MockMode::Ignore).await;
4048        // Short timeout keeps the test fast: 2 attempts × 200ms ≈ 400ms.
4049        let resolver = DnsResolver::new()
4050            .with_timeout(Duration::from_millis(200))
4051            .allowing_private_hosts()
4052            .with_port(port);
4053        let result = resolver
4054            .resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
4055            .await;
4056        match &result {
4057            Err(SeerError::DnsError(_)) => {}
4058            other => panic!("unanswered query must surface a DnsError, got: {other:?}"),
4059        }
4060        assert_eq!(classify_ns_presence(&result), DnsPresence::Unknown);
4061    }
4062
4063    #[tokio::test]
4064    async fn resolve_type_rejects_composite_types_consistently() {
4065        // SRV and ANY are composite queries owned by resolve(); the shared
4066        // dispatch must reject them identically for every entry point. The
4067        // rejection happens before any I/O, so this test never contacts a
4068        // server despite using the default resolver.
4069        let r = DnsResolver::new();
4070        for composite in [RecordType::SRV, RecordType::ANY] {
4071            let err = r
4072                .resolve_type(&r.default_target(), "seer.test", composite)
4073                .await
4074                .expect_err("composite types must be rejected by resolve_type");
4075            assert_eq!(
4076                err.to_string(),
4077                unsupported_record_type(composite).to_string()
4078            );
4079        }
4080    }
4081
4082    #[tokio::test]
4083    async fn mock_unanswered_probe_costs_a_positive_answer_only_the_grace() {
4084        // Regression: after the main answer landed, the probe was awaited
4085        // for its whole budget (two timeouts) when its server never
4086        // answered it. It now waits a fifth of one timeout at most.
4087        let (port, asked) = wildcard_zone(|| MockReply::NoReply).await;
4088        let resolver = DnsResolver::new()
4089            .with_timeout(Duration::from_secs(2))
4090            .allowing_private_hosts()
4091            .with_port(port);
4092        let started = Instant::now();
4093        let result = resolver
4094            .query("www.seer.test", RecordType::A, Some("127.0.0.1"))
4095            .await
4096            .expect("the main query is answered");
4097        let elapsed = started.elapsed();
4098        assert_eq!(result.records().count(), 1);
4099        assert_eq!(result.wildcard, None, "an unfinished probe says nothing");
4100        assert!(probes_sent(&asked) >= 1, "the probe was sent");
4101        // Grace 400ms; the old wait was the probe's 4s budget.
4102        assert!(elapsed >= Duration::from_millis(400), "{elapsed:?}");
4103        assert!(elapsed < Duration::from_millis(1500), "{elapsed:?}");
4104    }
4105
4106    /// A zone whose `www` answers every ANY sub-type but TXT and DNSKEY,
4107    /// which its server never answers.
4108    async fn partly_silent_zone() -> u16 {
4109        spawn_mock_dns_fn(|_, qtype| match qtype {
4110            HickoryRecordType::A => MockReply::Answer(vec![a_rdata([192, 0, 2, 7])]),
4111            HickoryRecordType::TXT | HickoryRecordType::DNSKEY => MockReply::NoReply,
4112            _ => MockReply::NoDataWithSoa("seer.test"),
4113        })
4114        .await
4115    }
4116
4117    fn short_resolver(port: u16) -> DnsResolver {
4118        DnsResolver::new()
4119            .with_timeout(Duration::from_millis(200))
4120            .allowing_private_hosts()
4121            .with_port(port)
4122    }
4123
4124    #[tokio::test]
4125    async fn mock_query_any_reports_the_types_that_failed() {
4126        // Regression: the timed-out TXT and DNSKEY sub-queries were dropped
4127        // silently, so the ANY answer read as complete.
4128        let port = partly_silent_zone().await;
4129        let result = short_resolver(port)
4130            .query("www.seer.test", RecordType::ANY, Some("127.0.0.1"))
4131            .await
4132            .expect("some sub-queries were answered");
4133        assert_eq!(result.records().count(), 1);
4134        let failed: Vec<RecordType> = result.failed_types.iter().map(|f| f.record_type).collect();
4135        assert_eq!(failed, [RecordType::TXT, RecordType::DNSKEY]);
4136        assert!(
4137            result.failed_types[0].error.contains("timed out"),
4138            "{:?}",
4139            result.failed_types
4140        );
4141
4142        // A single-type query never carries any.
4143        let a = short_resolver(port)
4144            .query("www.seer.test", RecordType::A, Some("127.0.0.1"))
4145            .await
4146            .unwrap();
4147        assert!(a.failed_types.is_empty());
4148    }
4149
4150    #[tokio::test]
4151    async fn mock_query_server_any_keeps_partial_failures_apart_from_silence() {
4152        let port = partly_silent_zone().await;
4153        let reply = short_resolver(port)
4154            .query_server("www.seer.test", RecordType::ANY, "127.0.0.1")
4155            .await
4156            .unwrap();
4157        let ServerReply::Response(result) = reply else {
4158            panic!("a partial answer is a response: {reply:?}");
4159        };
4160        assert_eq!(result.records().count(), 1);
4161        let failed: Vec<RecordType> = result.failed_types.iter().map(|f| f.record_type).collect();
4162        assert_eq!(failed, [RecordType::TXT, RecordType::DNSKEY]);
4163
4164        // No sub-query answered: the server is silent, not empty.
4165        let silent = spawn_mock_dns(MockMode::Ignore).await;
4166        let reply = short_resolver(silent)
4167            .query_server("www.seer.test", RecordType::ANY, "127.0.0.1")
4168            .await
4169            .unwrap();
4170        assert!(
4171            matches!(&reply, ServerReply::Silent(reason) if reason == "timed out"),
4172            "{reply:?}"
4173        );
4174    }
4175
4176    #[tokio::test]
4177    async fn resolve_validates_the_name_before_resolving_the_nameserver() {
4178        // Regression: the nameserver hostname was resolved (a network
4179        // lookup) before the name was checked. The bad name fails first,
4180        // without touching the network.
4181        let err = DnsResolver::new()
4182            .resolve(".bad..name", RecordType::A, Some("ns.seer.test"))
4183            .await
4184            .expect_err("an invalid name is rejected");
4185        assert!(
4186            matches!(
4187                err,
4188                SeerError::InvalidDomain(_) | SeerError::InvalidInput(_)
4189            ),
4190            "{err:?}"
4191        );
4192    }
4193
4194    #[tokio::test]
4195    async fn a_local_answer_never_resolves_the_nameserver_hostname() {
4196        // Regression: `dig -x 127.0.0.1 @ns.example` resolved `ns.example` over
4197        // the network though hickory answers that PTR itself. Only the
4198        // offline checks run: an unresolvable hostname is fine, a reserved
4199        // IP literal and a malformed spec are not.
4200        let r = DnsResolver::new();
4201        let result = r
4202            .query(
4203                "127.0.0.1",
4204                RecordType::PTR,
4205                Some("ns.does-not-resolve.test"),
4206            )
4207            .await
4208            .expect("answered locally, the nameserver unused");
4209        assert!(result.answered_locally);
4210        assert_eq!(result.records().count(), 1);
4211
4212        let blocked = r
4213            .query("127.0.0.1", RecordType::PTR, Some("10.0.0.1"))
4214            .await
4215            .expect_err("a reserved nameserver is refused all the same");
4216        assert!(blocked.to_string().contains("blocked"), "{blocked}");
4217        assert!(r
4218            .query("127.0.0.1", RecordType::PTR, Some("ftp://ns.seer.test"))
4219            .await
4220            .is_err());
4221    }
4222
4223    /// Regression: a nameserver given by hostname was resolved with no
4224    /// deadline of its own, so the bootstrap lookup's retransmissions
4225    /// (`attempts`) ran on before any query was sent. It is held to one
4226    /// timeout.
4227    #[tokio::test]
4228    async fn a_nameserver_hostname_lookup_is_held_to_one_timeout() {
4229        use crate::dns::test_support::{spawn_mock_dns_fn, MockReply};
4230
4231        let timeout = Duration::from_millis(500);
4232        let bootstrap = spawn_mock_dns_fn(|_, _| MockReply::NoReply).await;
4233        let resolver = DnsResolver::new()
4234            .with_timeout(timeout)
4235            .with_default_upstream_port(bootstrap)
4236            .allowing_private_hosts();
4237
4238        for use_query in [true, false] {
4239            let started = Instant::now();
4240            let err = if use_query {
4241                resolver
4242                    .query("seer.test", RecordType::A, Some("ns.bootstrap.test"))
4243                    .await
4244                    .expect_err("the hostname never resolves")
4245            } else {
4246                resolver
4247                    .resolve("seer.test", RecordType::A, Some("ns.bootstrap.test"))
4248                    .await
4249                    .expect_err("the hostname never resolves")
4250            };
4251            let elapsed = started.elapsed();
4252            assert!(err.to_string().contains("ns.bootstrap.test"), "{err}");
4253            // Two attempts took at least two timeouts.
4254            assert!(elapsed < timeout * 2 - timeout / 5, "took {elapsed:?}");
4255        }
4256    }
4257
4258    /// Regression: lookups through one custom nameserver waited on its memo
4259    /// slot while the first built the resolver, and a failed build left the
4260    /// slot empty, so each waiter then made its own attempt in turn: four
4261    /// concurrent lookups through a hostname that never resolves took four
4262    /// timeouts. They now fail together, in about one.
4263    #[tokio::test]
4264    async fn concurrent_lookups_through_a_failing_nameserver_fail_together() {
4265        use crate::dns::test_support::{spawn_mock_dns_fn, MockReply};
4266
4267        let timeout = Duration::from_millis(500);
4268        let bootstrap = spawn_mock_dns_fn(|_, _| MockReply::NoReply).await;
4269        let resolver = DnsResolver::new()
4270            .with_timeout(timeout)
4271            .with_default_upstream_port(bootstrap)
4272            .allowing_private_hosts();
4273        let lookup = || resolver.resolve("seer.test", RecordType::A, Some("ns.bootstrap.test"));
4274
4275        let started = Instant::now();
4276        let results = future::join_all((0..4).map(|_| lookup())).await;
4277        let elapsed = started.elapsed();
4278        for result in results {
4279            let err = result.expect_err("the hostname never resolves");
4280            assert!(err.to_string().contains("ns.bootstrap.test"), "{err}");
4281        }
4282        assert!(elapsed < timeout * 2, "took {elapsed:?}");
4283    }
4284
4285    /// Regression: `query()`'s two-timeout budget started only after the
4286    /// nameserver's hostname had resolved, so a slow lookup added on top.
4287    /// The budget now starts with the call.
4288    // Multi-thread: the bootstrap handler sleeps on its worker to delay a reply.
4289    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4290    async fn a_nameserver_hostname_lookup_spends_the_query_budget() {
4291        use crate::dns::test_support::{a_rdata, spawn_mock_dns_fn, MockReply};
4292
4293        let timeout = Duration::from_millis(800);
4294        let delay = timeout * 3 / 4;
4295        let bootstrap = spawn_mock_dns_fn(move |qname, qtype| match (qname, qtype) {
4296            // Two addresses, so a silent first one leaves failover to the
4297            // second for the budget to cut short.
4298            ("ns.bootstrap.test", HickoryRecordType::A) => {
4299                std::thread::sleep(delay);
4300                MockReply::Answer(vec![
4301                    a_rdata(Ipv4Addr::LOCALHOST),
4302                    a_rdata(Ipv4Addr::LOCALHOST),
4303                ])
4304            }
4305            _ => MockReply::NoData,
4306        })
4307        .await;
4308        // The nameserver itself never answers, on either address.
4309        let target = spawn_mock_dns_fn(|_, _| MockReply::NoReply).await;
4310        let resolver = DnsResolver::new()
4311            .with_timeout(timeout)
4312            .with_default_upstream_port(bootstrap)
4313            .allowing_private_hosts()
4314            .with_port(target);
4315
4316        let started = Instant::now();
4317        let err = resolver
4318            .query("seer.test", RecordType::A, Some("ns.bootstrap.test"))
4319            .await
4320            .expect_err("the nameserver is silent");
4321        let elapsed = started.elapsed();
4322        assert!(err.to_string().contains("timed out"), "{err}");
4323        assert!(
4324            !err.to_string().contains("ns.bootstrap.test"),
4325            "the hostname resolved: {err}"
4326        );
4327        // Two timeouts from the call; before, the lookup's delay came on top.
4328        let budget = timeout * crate::dns::transport::QUERY_BUDGET_TIMEOUTS;
4329        assert!(elapsed < budget + delay / 2, "took {elapsed:?}");
4330    }
4331}