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