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