Skip to main content

seer_core/dns/
resolver.rs

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