Skip to main content

seer_core/dns/
resolver.rs

1//! DNS resolution over hickory-resolver.
2//!
3//! Retry boundary (deliberate): unlike the WHOIS/RDAP clients, this module
4//! does NOT wrap queries in [`crate::retry::RetryPolicy`]. hickory-resolver
5//! already performs its own retransmission (`opts.attempts` below) against
6//! the configured nameserver within the per-query timeout; stacking an outer
7//! retry loop on top would multiply worst-case latency without improving
8//! resolution odds. If a retry knob is ever needed here, tune
9//! `ResolverOpts::attempts` rather than adding a wrapper.
10
11use std::net::IpAddr;
12use std::str::FromStr;
13use std::sync::Arc;
14use std::time::Duration;
15
16use hickory_resolver::config::{
17    NameServerConfig, ResolveHosts, ResolverConfig, ResolverOpts, ServerOrderingStrategy, GOOGLE,
18};
19use hickory_resolver::net::runtime::TokioRuntimeProvider;
20use hickory_resolver::net::NetError;
21use hickory_resolver::proto::dnssec::PublicKey;
22use hickory_resolver::proto::rr::rdata::CAA;
23use hickory_resolver::proto::rr::{RData as HickoryRData, RecordType as HickoryRecordType};
24use hickory_resolver::TokioResolver;
25use tracing::{debug, instrument};
26
27use super::nameserver::{NameserverProtocol, NameserverSpec};
28use super::records::{DnsRecord, RecordData, RecordType};
29use crate::error::{Result, SeerError};
30use crate::validation::{normalize_domain, normalize_host};
31
32/// Convert a DNS lookup result, treating "no records found" as an empty vec
33/// rather than an error. This is correct DNS behavior — the absence of a
34/// record type for a domain is a valid response (NODATA), not a failure.
35fn dns_lookup_or_empty<T>(
36    result: std::result::Result<T, NetError>,
37    record_type: &str,
38) -> Result<Option<T>> {
39    match result {
40        Ok(response) => Ok(Some(response)),
41        Err(e) if e.is_no_records_found() => Ok(None),
42        Err(e) => Err(SeerError::DnsError(format!(
43            "{} lookup failed: {}",
44            record_type, e
45        ))),
46    }
47}
48
49/// Default timeout for DNS queries (5 seconds).
50/// DNS is typically fast; longer timeouts indicate network issues or unreachable servers.
51const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
52
53/// Apply seer's standard resolver options.
54///
55/// Extracted from [`build_resolver`] so the option set is assertable without
56/// standing up a resolver (hickory exposes no getter for a built resolver's
57/// options), and shared with `net.rs`'s SSRF fallback resolver so the two
58/// cannot drift — they previously carried hand-copied option sets and the
59/// server-ordering fix below would have landed in only one of them.
60pub(crate) fn apply_standard_opts(opts: &mut ResolverOpts, timeout: Duration) {
61    opts.timeout = timeout;
62    opts.attempts = 2;
63    opts.use_hosts_file = ResolveHosts::Never;
64    // Pin the query order to the configured list instead of hickory's default
65    // `QueryStatistics`. The default orders servers by observed performance,
66    // which is the right call for a long-lived process but meaningless in a
67    // short-lived CLI run that has collected no statistics yet — leaving the
68    // effective order arbitrary.
69    //
70    // That mattered because the default upstream group (`GOOGLE`) carries two
71    // IPv4 and two IPv6 addresses, and hickory queries `num_concurrent_reqs`
72    // (2) of them in parallel. On a host advertising an IPv6 default route
73    // with no working IPv6 transit — RA-advertised IPv6 that black-holes,
74    // common on consumer networks — a pair that happened to be both IPv6 sent
75    // into the void and burned the full per-query timeout. Roughly one run in
76    // six stalled 5s, and `seer doctor` reported an outright DNS failure
77    // because its probe deadline equals that timeout, leaving no budget to
78    // fail over.
79    //
80    // With the order pinned, the parallel pair is deterministically the two
81    // IPv4 servers. IPv6 entries stay in the list and remain reachable as
82    // fallback: on a genuinely IPv6-only host the IPv4 sends fail immediately
83    // with ENETUNREACH rather than timing out, so failover stays fast.
84    opts.server_ordering_strategy = ServerOrderingStrategy::UserProvidedOrder;
85}
86
87/// Build a TokioResolver pre-configured with the given upstream config and
88/// our standard options (timeout, retries, no hosts-file consultation).
89///
90/// Build only fails when hickory cannot construct its rustls TLS context
91/// (needed for DoT/DoH upstreams). With the `webpki-roots` feature supplying
92/// the root store, that construction is infallible in practice, but the
93/// fallible signature is kept honest so a future root-store change degrades
94/// to a typed error instead of a panic.
95fn build_resolver(config: ResolverConfig, timeout: Duration) -> Result<TokioResolver> {
96    let mut builder = TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
97    apply_standard_opts(builder.options_mut(), timeout);
98    builder
99        .build()
100        .map_err(|e| SeerError::DnsError(format!("failed to construct DNS resolver: {}", e)))
101}
102
103/// Build the default (Google DNS over UDP/TCP) resolver.
104///
105/// The `expect` expresses an invariant rather than laziness: with the
106/// `webpki-roots` root store compiled in, hickory's TLS-context construction
107/// (the only fallible step in [`build_resolver`]) cannot fail, and the
108/// infallible `new()`/`with_timeout()` constructors predate DoT/DoH support.
109fn build_default_resolver(timeout: Duration) -> TokioResolver {
110    build_resolver(ResolverConfig::udp_and_tcp(&GOOGLE), timeout)
111        .expect("default resolver build cannot fail with the bundled webpki root store")
112}
113
114/// Upstream config pinned to the single server `ip:port` — UDP only, or UDP
115/// with TCP fallback (for answers that may truncate).
116pub(crate) fn single_server_config(ip: IpAddr, port: u16, tcp_fallback: bool) -> ResolverConfig {
117    let mut ns = if tcp_fallback {
118        NameServerConfig::udp_and_tcp(ip)
119    } else {
120        NameServerConfig::udp(ip)
121    };
122    for connection in &mut ns.connections {
123        connection.port = port;
124    }
125    let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
126    config.add_name_server(ns);
127    config
128}
129
130/// Google DNS (UDP+TCP), or the pinned UDP `upstream` that the DNSSEC and
131/// delegation checkers' `#[cfg(test)]` seams point at a loopback mock.
132pub(crate) fn google_or_pinned(upstream: Option<(IpAddr, u16)>) -> ResolverConfig {
133    match upstream {
134        None => ResolverConfig::udp_and_tcp(&GOOGLE),
135        Some((ip, port)) => single_server_config(ip, port, false),
136    }
137}
138
139/// Appends the root dot so hickory treats `name` as fully qualified (no
140/// search-list expansion).
141pub(crate) fn fqdn(name: &str) -> String {
142    if name.ends_with('.') {
143        name.to_string()
144    } else {
145        format!("{name}.")
146    }
147}
148
149/// Build the hickory upstream config for a parsed nameserver spec and its
150/// resolved (and already SSRF-validated) addresses.
151///
152/// One `NameServerConfig` is added per IP (IPv4 first, see below), all
153/// speaking the spec's protocol on the spec's port. For DoT/DoH the TLS
154/// server name is the spec's host — the hostname when one was given, or the
155/// IP literal itself (verified against the certificate's IP SANs, which the
156/// major public resolvers carry). `port_override` is the `#[cfg(test)]`
157/// mock-server seam and is always `None` in production.
158fn build_upstream_config(
159    spec: &NameserverSpec,
160    ips: &[IpAddr],
161    port_override: Option<u16>,
162) -> ResolverConfig {
163    let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
164    let port = port_override.unwrap_or(spec.port);
165    // IPv4 before IPv6, each family keeping its resolved order — the same
166    // shape as the default `GOOGLE` group. The pool is pinned to
167    // `UserProvidedOrder` (see `apply_standard_opts`) and races its first
168    // `num_concurrent_reqs` (2) entries under one deadline equal to the
169    // per-query timeout, so list order decides which addresses get tried.
170    // hickory's `lookup_ip` returns AAAA before A, so a dual-stack hostname
171    // (`dns.google`, `https://cloudflare-dns.com/dns-query`) led with two
172    // IPv6 entries; with black-holed IPv6 transit they spent the whole
173    // deadline and the IPv4 entries were never reached. IPv6 stays as
174    // fallback: on an IPv6-only host IPv4 sends fail fast (ENETUNREACH).
175    let ordered = ips
176        .iter()
177        .filter(|ip| ip.is_ipv4())
178        .chain(ips.iter().filter(|ip| ip.is_ipv6()));
179    for ip in ordered {
180        let mut ns = match spec.protocol {
181            NameserverProtocol::Udp => NameServerConfig::udp(*ip),
182            NameserverProtocol::Tls => NameServerConfig::tls(*ip, Arc::from(spec.tls_name())),
183            NameserverProtocol::Https => NameServerConfig::https(
184                *ip,
185                Arc::from(spec.tls_name()),
186                spec.path.as_deref().map(Arc::from),
187            ),
188        };
189        for connection in &mut ns.connections {
190            connection.port = port;
191        }
192        config.add_name_server(ns);
193    }
194    config
195}
196
197/// DNS resolver for querying various record types.
198///
199/// Uses Google DNS (8.8.8.8) by default, but supports custom nameservers
200/// over plain UDP, DNS over TLS (`tls://`), and DNS over HTTPS (`https://`)
201/// — see [`NameserverSpec`] for the accepted forms.
202/// The default resolver is cached and reused across queries to avoid
203/// repeated initialization overhead.
204#[derive(Clone)]
205pub struct DnsResolver {
206    timeout: Duration,
207    /// Cached default resolver (Google DNS). Reused across all queries
208    /// that don't specify a custom nameserver.
209    default_resolver: TokioResolver,
210    /// Port override for custom-nameserver queries. Always `None` in
211    /// production (the port comes from the parsed [`NameserverSpec`]);
212    /// settable only through the `#[cfg(test)]` seam so mock-server tests
213    /// can bind an ephemeral local port.
214    port_override: Option<u16>,
215    /// When true, skips the SSRF/reserved-IP validation on custom
216    /// nameservers so tests can point the resolver at a 127.0.0.1 fixture.
217    /// Not settable outside `#[cfg(test)]` builds — production paths always
218    /// validate.
219    allow_private_hosts: bool,
220    /// Test-only: nameserver spec used when a caller passes `None`, so code
221    /// that always queries the default upstream (posture, CAA) can be pointed
222    /// at the loopback fixture. Absent from release builds.
223    #[cfg(test)]
224    test_default_nameserver: Option<String>,
225}
226
227impl std::fmt::Debug for DnsResolver {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        f.debug_struct("DnsResolver")
230            .field("timeout", &self.timeout)
231            .finish()
232    }
233}
234
235impl Default for DnsResolver {
236    fn default() -> Self {
237        Self::new()
238    }
239}
240
241impl DnsResolver {
242    /// Creates a new DNS resolver with default settings.
243    pub fn new() -> Self {
244        Self {
245            timeout: DEFAULT_TIMEOUT,
246            default_resolver: build_default_resolver(DEFAULT_TIMEOUT),
247            port_override: None,
248            allow_private_hosts: false,
249            #[cfg(test)]
250            test_default_nameserver: None,
251        }
252    }
253
254    /// Builds a resolver honoring `~/.seer/config.toml` settings.
255    ///
256    /// Reads `timeouts.dns_secs` (already clamped to 1–60s by
257    /// [`crate::config::SeerConfig::load`]). Sugar over
258    /// [`DnsResolver::with_timeout`] — equivalent to
259    /// `DnsResolver::new().with_timeout(config.dns_timeout())`.
260    ///
261    /// The `nameserver` config key is deliberately NOT applied here: the
262    /// resolver takes the nameserver per-query (see [`DnsResolver::resolve`]),
263    /// so callers thread `config.nameserver` at the call site where it can be
264    /// overridden per-invocation.
265    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
266        Self::new().with_timeout(config.dns_timeout())
267    }
268
269    /// Test-only: the per-query timeout, for asserting config plumbing.
270    #[cfg(test)]
271    pub(crate) fn timeout(&self) -> Duration {
272        self.timeout
273    }
274
275    /// Test-only: allow custom nameservers on loopback/private hosts (mock servers).
276    #[cfg(test)]
277    pub(crate) fn allowing_private_hosts(mut self) -> Self {
278        self.allow_private_hosts = true;
279        self
280    }
281
282    /// Test-only: query custom nameservers on a non-standard port (mock
283    /// servers bind ephemeral ports).
284    #[cfg(test)]
285    pub(crate) fn with_port(mut self, port: u16) -> Self {
286        self.port_override = Some(port);
287        self
288    }
289
290    /// Test-only: send queries that name no nameserver to `nameserver`
291    /// instead of the cached default (Google) resolver.
292    #[cfg(test)]
293    pub(crate) fn with_default_nameserver(mut self, nameserver: &str) -> Self {
294        self.test_default_nameserver = Some(nameserver.to_string());
295        self
296    }
297
298    /// The nameserver a query actually uses: the caller's choice, or (in test
299    /// builds only) the fixture default installed by
300    /// [`with_default_nameserver`](Self::with_default_nameserver).
301    #[cfg(test)]
302    fn effective_nameserver<'a>(&'a self, nameserver: Option<&'a str>) -> Option<&'a str> {
303        nameserver.or(self.test_default_nameserver.as_deref())
304    }
305
306    #[cfg(not(test))]
307    fn effective_nameserver<'a>(&self, nameserver: Option<&'a str>) -> Option<&'a str> {
308        nameserver
309    }
310
311    /// Sets the timeout for DNS queries.
312    ///
313    /// The default is 5 seconds, which is sufficient for most DNS queries.
314    pub fn with_timeout(mut self, timeout: Duration) -> Self {
315        self.timeout = timeout;
316        self.default_resolver = build_default_resolver(timeout);
317        self
318    }
319
320    async fn create_custom_resolver(&self, nameserver: &str) -> Result<TokioResolver> {
321        // Parse the spec first: bare IP/host (UDP), tls:// (DoT), https://
322        // (DoH). Every surface (CLI, REPL, config.toml, py/REST/MCP) funnels
323        // its opaque nameserver string through here, so this one parse gives
324        // all of them every transport.
325        let spec = NameserverSpec::parse(nameserver)?;
326
327        // Accept either a literal IP or a hostname. For hostnames, resolve
328        // via the default (Google DNS) hickory resolver so we do not depend
329        // on the OS resolver — that is the same fallback principle as the
330        // SSL probe fix: when the local system resolver is broken (split
331        // DNS, broken router, container netns), hickory still reaches the
332        // public name servers and the user-supplied authoritative server
333        // is still usable. DoT/DoH hostnames bootstrap-resolve through this
334        // exact same path (the TLS handshake still verifies the hostname).
335        let ips: Vec<IpAddr> = if let Ok(ip) = spec.host.parse::<IpAddr>() {
336            vec![ip]
337        } else {
338            let response = self
339                .default_resolver
340                .lookup_ip(spec.host.as_str())
341                .await
342                .map_err(|e| {
343                    SeerError::DnsError(format!(
344                        "failed to resolve nameserver hostname {}: {}",
345                        spec.host, e
346                    ))
347                })?;
348            let resolved: Vec<IpAddr> = response.iter().collect();
349            if resolved.is_empty() {
350                return Err(SeerError::DnsError(format!(
351                    "nameserver {} did not resolve to any addresses",
352                    spec.host
353                )));
354            }
355            resolved
356        };
357
358        // SSRF protection: reject private/reserved IPs — whether supplied
359        // literally or returned by name resolution, and identically for
360        // UDP, tls://, and https:// specs. Without this, a hostname under
361        // attacker control could point at internal infra.
362        // `allow_private_hosts` is only settable via the `#[cfg(test)]`
363        // seam; production builds always validate.
364        if !self.allow_private_hosts {
365            for ip in &ips {
366                if let Some(reason) = crate::validation::describe_reserved_ip(ip) {
367                    return Err(SeerError::DnsError(format!(
368                        "nameserver {} blocked: {}",
369                        nameserver, reason
370                    )));
371                }
372            }
373        }
374
375        build_resolver(
376            build_upstream_config(&spec, &ips, self.port_override),
377            self.timeout,
378        )
379    }
380
381    /// Resolves DNS records for a domain.
382    ///
383    /// # Arguments
384    /// * `domain` - The domain name to query
385    /// * `record_type` - The type of DNS record to look up (A, AAAA, MX, etc.)
386    /// * `nameserver` - Optional custom nameserver spec; uses Google DNS if
387    ///   None. Accepts a bare IP/hostname with optional port (UDP),
388    ///   `tls://host[:port]` (DNS over TLS), or `https://host[:port][/path]`
389    ///   (DNS over HTTPS) — see [`NameserverSpec`]
390    #[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
391    pub async fn resolve(
392        &self,
393        domain: &str,
394        record_type: RecordType,
395        nameserver: Option<&str>,
396    ) -> Result<Vec<DnsRecord>> {
397        // Reuse the cached default resolver when no custom nameserver is specified
398        let custom_resolver;
399        let resolver = if let Some(ns) = self.effective_nameserver(nameserver) {
400            custom_resolver = self.create_custom_resolver(ns).await?;
401            &custom_resolver
402        } else {
403            &self.default_resolver
404        };
405        let domain = prepare_query(domain, record_type)?;
406
407        debug!(nameserver = nameserver.unwrap_or("system"), "Resolving DNS");
408
409        match record_type {
410            RecordType::SRV => match parse_srv_query(&domain) {
411                // dig-style `_service._proto.name` queries resolve directly.
412                Some((service, protocol, name)) => {
413                    self.resolve_srv_core(resolver, &service, &protocol, &name)
414                        .await
415                }
416                // A bare domain isn't a valid SRV query — surface a usage hint
417                // as an input error (permanent), not a transient DNS failure.
418                None => Err(srv_format_error()),
419            },
420            RecordType::ANY => self.resolve_any(resolver, &domain).await,
421            single => self.resolve_type(resolver, &domain, single).await,
422        }
423    }
424
425    /// Core SRV resolution against an already-built resolver, behind the
426    /// `dig`-style `_service._proto.name` path in [`resolve`](Self::resolve).
427    /// Validates the service/protocol labels (DNS query-injection guard) then
428    /// queries `_service._proto.domain`. Label-validation failures are
429    /// [`SeerError::InvalidInput`] — they are caller mistakes, not transient
430    /// DNS failures, so they must not be advertised as retryable.
431    async fn resolve_srv_core(
432        &self,
433        resolver: &TokioResolver,
434        service: &str,
435        protocol: &str,
436        domain: &str,
437    ) -> Result<Vec<DnsRecord>> {
438        if !is_valid_srv_label(service) {
439            return Err(SeerError::InvalidInput(format!(
440                "invalid SRV service name: {}",
441                service
442            )));
443        }
444        if !is_valid_srv_label(protocol) {
445            return Err(SeerError::InvalidInput(format!(
446                "invalid SRV protocol name: {}",
447                protocol
448            )));
449        }
450
451        let query_name = format!("_{}._{}.{}", service, protocol, domain);
452
453        let Some(response) = dns_lookup_or_empty(
454            resolver.lookup(&query_name, HickoryRecordType::SRV).await,
455            "SRV",
456        )?
457        else {
458            return Ok(vec![]);
459        };
460
461        let records = response
462            .answers()
463            .iter()
464            .filter_map(|record| {
465                if let HickoryRData::SRV(srv) = &record.data {
466                    Some(DnsRecord {
467                        name: query_name.clone(),
468                        record_type: RecordType::SRV,
469                        ttl: record.ttl,
470                        data: RecordData::SRV {
471                            priority: srv.priority,
472                            weight: srv.weight,
473                            port: srv.port,
474                            target: srv.target.to_string(),
475                        },
476                    })
477                } else {
478                    None
479                }
480            })
481            .collect();
482
483        Ok(records)
484    }
485
486    /// Single-type dispatch shared by [`resolve`](Self::resolve) and
487    /// [`resolve_any`](Self::resolve_any) — the one place a seer [`RecordType`] is routed to a
488    /// lookup, so the two entry points cannot diverge.
489    ///
490    /// `SRV` and `ANY` are composite queries owned by `resolve` (label
491    /// validation / fan-out); requesting them here yields the same
492    /// "unsupported record type" error from either entry point.
493    async fn resolve_type(
494        &self,
495        resolver: &TokioResolver,
496        domain: &str,
497        record_type: RecordType,
498    ) -> Result<Vec<DnsRecord>> {
499        match record_type {
500            // PTR accepts a raw IP literal, which is queried as its
501            // reverse-DNS name (and reported under that name).
502            RecordType::PTR => {
503                let query = if let Ok(ip) = IpAddr::from_str(domain) {
504                    reverse_dns_name(&ip)
505                } else {
506                    domain.to_string()
507                };
508                self.resolve_records(resolver, &query, RecordType::PTR)
509                    .await
510            }
511            single => self.resolve_records(resolver, domain, single).await,
512        }
513    }
514
515    /// Generic single-type lookup: queries the wire type for `record_type`
516    /// and maps each matching answer through [`convert_rdata`]. Answers of
517    /// other types (e.g. a CNAME returned alongside A records) are skipped.
518    /// NXDOMAIN/NODATA fold to an empty vec (see [`dns_lookup_or_empty`]).
519    ///
520    /// MX is the one type with a meaningful intra-response order: answers
521    /// are sorted by preference so the highest-priority exchange is first.
522    async fn resolve_records(
523        &self,
524        resolver: &TokioResolver,
525        domain: &str,
526        record_type: RecordType,
527    ) -> Result<Vec<DnsRecord>> {
528        let Some(wire_type) = wire_type(record_type) else {
529            return Err(unsupported_record_type(record_type));
530        };
531
532        let Some(response) = dns_lookup_or_empty(
533            resolver.lookup(domain, wire_type).await,
534            &record_type.to_string(),
535        )?
536        else {
537            return Ok(vec![]);
538        };
539
540        let mut records: Vec<DnsRecord> = response
541            .answers()
542            .iter()
543            .filter_map(|record| {
544                convert_rdata(record_type, &record.data).map(|data| DnsRecord {
545                    name: domain.to_string(),
546                    record_type,
547                    ttl: record.ttl,
548                    data,
549                })
550            })
551            .collect();
552
553        if record_type == RecordType::MX {
554            records.sort_by_key(|r| match &r.data {
555                RecordData::MX { preference, .. } => *preference,
556                _ => 0,
557            });
558        }
559
560        Ok(records)
561    }
562
563    async fn resolve_any(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
564        // Query common record types concurrently — previously these ran
565        // serially, making `ANY` ~7x slower than a single query (#61).
566        // `join_all` preserves input order, so the merged record list keeps the
567        // A, AAAA, MX, … ordering.
568        let record_types = [
569            RecordType::A,
570            RecordType::AAAA,
571            RecordType::MX,
572            RecordType::NS,
573            RecordType::TXT,
574            RecordType::SOA,
575            RecordType::CAA,
576        ];
577
578        let results = futures::future::join_all(
579            record_types
580                .into_iter()
581                .map(|record_type| self.resolve_type(resolver, domain, record_type)),
582        )
583        .await;
584
585        // Track whether any sub-query actually succeeded (an empty answer
586        // for an existing domain still counts as success). If every type
587        // errored — e.g. the resolver is unreachable — surface that error
588        // rather than returning an empty set that reads as "no records".
589        let mut all_records = Vec::new();
590        let mut any_ok = false;
591        let mut last_err = None;
592        for result in results {
593            match result {
594                Ok(records) => {
595                    any_ok = true;
596                    all_records.extend(records);
597                }
598                Err(e) => last_err = Some(e),
599            }
600        }
601
602        match last_err {
603            Some(e) if !any_ok => Err(e),
604            _ => Ok(all_records),
605        }
606    }
607}
608
609/// Whether a domain appears to exist in the public DNS. Used as a
610/// corroborating availability signal when registry data (RDAP/WHOIS) is
611/// inconclusive — e.g. a thin/blocked WHOIS body and an RDAP failure that is
612/// not an authoritative 404.
613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
614pub enum DnsPresence {
615    /// The apex returned NS records — the domain is delegated and exists.
616    Present,
617    /// NXDOMAIN / empty answer — the domain has no DNS presence.
618    Absent,
619    /// The DNS query itself failed; presence is unknown.
620    Unknown,
621}
622
623/// Maps an apex NS lookup result to a [`DnsPresence`]. Pure so the mapping is
624/// unit-testable without a live resolver. `resolve(.., NS, ..)` already folds
625/// NXDOMAIN/NODATA into `Ok(vec![])` (see `dns_lookup_or_empty`), so an empty
626/// `Ok` is the "no presence" signal and an `Err` is a genuine query failure.
627fn classify_ns_presence(result: &Result<Vec<DnsRecord>>) -> DnsPresence {
628    match result {
629        Ok(records) if records.is_empty() => DnsPresence::Absent,
630        Ok(_) => DnsPresence::Present,
631        Err(_) => DnsPresence::Unknown,
632    }
633}
634
635impl DnsResolver {
636    /// Probes whether a domain has any DNS presence by querying its apex NS
637    /// records. A registered, delegated domain returns NS records; an
638    /// unregistered domain returns NXDOMAIN (an empty record set).
639    ///
640    /// This is a heuristic, not proof: a registered-but-undelegated domain
641    /// also has no NS records, so callers should treat
642    /// [`DnsPresence::Absent`] as "likely available" (medium confidence).
643    pub async fn presence(&self, domain: &str) -> DnsPresence {
644        // A registration-level question: `www.example.com` means
645        // `example.com` here (as it does for WHOIS/RDAP), so strip `www.`
646        // explicitly — `resolve` itself now keeps it (see `prepare_query`).
647        let Ok(apex) = normalize_domain(domain) else {
648            return DnsPresence::Unknown;
649        };
650        classify_ns_presence(&self.resolve(&apex, RecordType::NS, None).await)
651    }
652}
653
654/// Prepares the query name for a DNS record lookup.
655///
656/// Record queries are about one exact DNS name, so this normalizes with
657/// [`normalize_host`], which — unlike [`normalize_domain`] — keeps a leading
658/// `www.`: `www` routinely carries its own records (typically a CNAME), and
659/// stripping it silently answered `dig www.example.com CNAME` for the apex.
660///
661/// PTR queries may be given a raw IP literal. IPv6 literals in particular must
662/// NOT pass through the normalizer: its trailing-`:port` strip heuristic
663/// truncates the final hextet (e.g. `::1111` → dropped) and the remaining `:`
664/// separators then fail character validation, so IPv6 reverse lookups errored
665/// out with "Invalid domain name" before ever reaching `resolve_ptr`. For PTR
666/// queries we therefore detect an IP literal up front and pass it through in
667/// canonical form; everything else (domains, and PTR queries given a
668/// reverse-DNS name such as `1.1.1.1.in-addr.arpa`) is normalized as usual.
669///
670/// Shared (crate-internal) with compare / propagation / follow so every entry
671/// point that stores or echoes the queried name agrees with what `resolve`
672/// actually queries — they previously re-normalized with `normalize_domain`,
673/// losing `www.` and mangling IPv6 PTR literals.
674pub(crate) fn prepare_query(domain: &str, record_type: RecordType) -> Result<String> {
675    prepare_query_with(domain, record_type, normalize_host)
676}
677
678/// [`prepare_query`] with the normalizer injected, so the IP-literal PTR path
679/// is testable against a rejecting normalizer (the real one reads the
680/// process-global `SEER_DOMAIN_ALLOWLIST`, which tests cannot set).
681fn prepare_query_with(
682    domain: &str,
683    record_type: RecordType,
684    normalize: impl Fn(&str) -> Result<String>,
685) -> Result<String> {
686    if record_type == RecordType::PTR {
687        if let Ok(ip) = IpAddr::from_str(domain.trim()) {
688            // `SEER_DOMAIN_ALLOWLIST` is enforced inside the normalizer, which
689            // an IP literal skips. Run the name that will actually be queried
690            // (the reverse-DNS name) through it, so a PTR query for an IP
691            // obeys the allowlist exactly like the equivalent
692            // `x.x.x.x.in-addr.arpa` query does.
693            normalize(&reverse_dns_name(&ip))?;
694            return Ok(ip.to_string());
695        }
696    }
697    normalize(domain)
698}
699
700/// Parses a `dig`-style SRV query name of the form `_service._proto.name` into
701/// its `(service, protocol, name)` parts, with the leading underscores
702/// stripped. Returns `None` when the input is not in that shape — e.g. a bare
703/// domain with no service/proto labels — so callers can surface a usage hint.
704pub(crate) fn parse_srv_query(name: &str) -> Option<(String, String, String)> {
705    let mut parts = name.splitn(3, '.');
706    let service = parts.next()?.strip_prefix('_')?;
707    let protocol = parts.next()?.strip_prefix('_')?;
708    let rest = parts.next()?;
709    if service.is_empty() || protocol.is_empty() || rest.is_empty() {
710        return None;
711    }
712    Some((service.to_string(), protocol.to_string(), rest.to_string()))
713}
714
715/// The canonical "bad SRV query name" error, shared by the single-query resolver
716/// path and the propagation checker so both reject a bare-domain SRV query with
717/// the identical permanent `InvalidInput` message.
718pub(crate) fn srv_format_error() -> SeerError {
719    SeerError::InvalidInput(
720        "SRV records require service name format: _service._proto.name".to_string(),
721    )
722}
723
724fn reverse_dns_name(ip: &IpAddr) -> String {
725    match ip {
726        IpAddr::V4(addr) => {
727            let octets = addr.octets();
728            format!(
729                "{}.{}.{}.{}.in-addr.arpa",
730                octets[3], octets[2], octets[1], octets[0]
731            )
732        }
733        IpAddr::V6(addr) => {
734            let segments = addr.segments();
735            // 32 hex nibbles + 31 dots + ".ip6.arpa" (9) = 72 chars
736            let mut result = String::with_capacity(72);
737            let mut first = true;
738            for segment in segments.iter().rev() {
739                for shift in [0, 4, 8, 12] {
740                    if !first {
741                        result.push('.');
742                    }
743                    first = false;
744                    let nibble = (segment >> shift) & 0xF;
745                    result
746                        .push(char::from_digit(nibble as u32, 16).expect("nibble is always 0-15"));
747                }
748            }
749            result.push_str(".ip6.arpa");
750            result
751        }
752    }
753}
754
755fn parse_caa(caa: &CAA) -> (u8, String, String) {
756    // hickory 0.26: CAA fields are public. `flags()` reassembles the full
757    // wire flags byte — the issuer-critical bit plus the reserved bits hickory
758    // keeps in `reserved_flags` — so the reported value matches what the zone
759    // publishes (rebuilding it from `issuer_critical` alone dropped the
760    // reserved bits). `value` is a `Vec<u8>` because RFC 8659 permits binary
761    // values for unknown property types. For seer's reporting purposes the
762    // common tags (issue/issuewild/iodef) are always UTF-8, so a lossy
763    // conversion preserves prior behavior without panicking on the rare
764    // binary case.
765    let flags = caa.flags();
766    let tag = caa.tag.clone();
767    let value = String::from_utf8_lossy(&caa.value).to_string();
768    (flags, tag, value)
769}
770
771/// Maps a concrete seer [`RecordType`] to the hickory wire type it queries.
772///
773/// `SRV` and `ANY` are composite lookups with dedicated paths
774/// (`resolve_srv_core` / `resolve_any`) and deliberately have no mapping
775/// here — asking [`DnsResolver::resolve_type`] for them is an error.
776fn wire_type(record_type: RecordType) -> Option<HickoryRecordType> {
777    Some(match record_type {
778        RecordType::A => HickoryRecordType::A,
779        RecordType::AAAA => HickoryRecordType::AAAA,
780        RecordType::CNAME => HickoryRecordType::CNAME,
781        RecordType::MX => HickoryRecordType::MX,
782        RecordType::NS => HickoryRecordType::NS,
783        RecordType::TXT => HickoryRecordType::TXT,
784        RecordType::SOA => HickoryRecordType::SOA,
785        RecordType::PTR => HickoryRecordType::PTR,
786        RecordType::CAA => HickoryRecordType::CAA,
787        RecordType::DNSKEY => HickoryRecordType::DNSKEY,
788        RecordType::DS => HickoryRecordType::DS,
789        // TLSA queries are how DANE clients discover the certificate
790        // association data for a TLS endpoint. The convention is
791        // `_<port>._<proto>.<host>` (e.g. `_443._tcp.example.com`); seer
792        // does not enforce the label shape because TLSA is also used for
793        // other transports.
794        RecordType::TLSA => HickoryRecordType::TLSA,
795        RecordType::SSHFP => HickoryRecordType::SSHFP,
796        RecordType::NAPTR => HickoryRecordType::NAPTR,
797        RecordType::SRV | RecordType::ANY => return None,
798    })
799}
800
801/// The canonical error for record types that cannot be resolved as a single
802/// wire query, shared by every dispatch path so the message never diverges.
803fn unsupported_record_type(record_type: RecordType) -> SeerError {
804    SeerError::DnsError(format!("unsupported record type: {}", record_type))
805}
806
807/// Uppercase hex rendering for wire-format byte fields (DS digests, TLSA
808/// certificate data, SSHFP fingerprints), matching dig's presentation.
809fn hex_upper(bytes: &[u8]) -> String {
810    bytes.iter().map(|b| format!("{:02X}", b)).collect()
811}
812
813/// Converts one hickory answer's RData into our [`RecordData`], if it is the
814/// variant `record_type` asked for. Any other RData in the answer section
815/// (e.g. a CNAME returned alongside A records) yields `None` and is skipped.
816///
817/// This is the single RData→RecordData conversion table used by every
818/// resolution path.
819fn convert_rdata(record_type: RecordType, data: &HickoryRData) -> Option<RecordData> {
820    use hickory_resolver::proto::dnssec::rdata::DNSSECRData;
821
822    match (record_type, data) {
823        (RecordType::A, HickoryRData::A(addr)) => Some(RecordData::A {
824            address: addr.0.to_string(),
825        }),
826        (RecordType::AAAA, HickoryRData::AAAA(addr)) => Some(RecordData::AAAA {
827            address: addr.0.to_string(),
828        }),
829        (RecordType::CNAME, HickoryRData::CNAME(cname)) => Some(RecordData::CNAME {
830            target: cname.0.to_string(),
831        }),
832        (RecordType::MX, HickoryRData::MX(mx)) => Some(RecordData::MX {
833            preference: mx.preference,
834            exchange: mx.exchange.to_string(),
835        }),
836        (RecordType::NS, HickoryRData::NS(ns)) => Some(RecordData::NS {
837            nameserver: ns.0.to_string(),
838        }),
839        (RecordType::TXT, HickoryRData::TXT(txt)) => Some(RecordData::TXT {
840            text: txt
841                .txt_data
842                .iter()
843                .map(|data| String::from_utf8_lossy(data).to_string())
844                .collect::<Vec<_>>()
845                .join(""),
846        }),
847        (RecordType::SOA, HickoryRData::SOA(soa)) => Some(RecordData::SOA {
848            mname: soa.mname.to_string(),
849            rname: soa.rname.to_string(),
850            serial: soa.serial,
851            // hickory models refresh/retry/expire as i32, but they are
852            // unsigned 32-bit wire intervals. A value >= 2^31 arrives as a
853            // negative i32; `try_into()` would fail and zero it out, hiding
854            // the real (large) value. `as u32` reinterprets the bits to the
855            // correct unsigned value instead.
856            refresh: soa.refresh as u32,
857            retry: soa.retry as u32,
858            expire: soa.expire as u32,
859            minimum: soa.minimum,
860        }),
861        (RecordType::PTR, HickoryRData::PTR(ptr)) => Some(RecordData::PTR {
862            target: ptr.0.to_string(),
863        }),
864        (RecordType::CAA, HickoryRData::CAA(caa)) => {
865            let (flags, tag, value) = parse_caa(caa);
866            Some(RecordData::CAA { flags, tag, value })
867        }
868        (RecordType::DNSKEY, HickoryRData::DNSSEC(DNSSECRData::DNSKEY(dnskey))) => {
869            use base64::{engine::general_purpose::STANDARD, Engine};
870            let public_key_buf = dnskey.public_key();
871            Some(RecordData::DNSKEY {
872                flags: dnskey.flags(),
873                // Protocol is always 3 for DNSSEC (RFC 4034)
874                protocol: 3,
875                algorithm: u8::from(public_key_buf.algorithm()),
876                public_key: STANDARD.encode(public_key_buf.public_bytes()),
877            })
878        }
879        (RecordType::DS, HickoryRData::DNSSEC(DNSSECRData::DS(ds))) => Some(RecordData::DS {
880            key_tag: ds.key_tag(),
881            algorithm: u8::from(ds.algorithm()),
882            digest_type: u8::from(ds.digest_type()),
883            digest: hex_upper(ds.digest()),
884        }),
885        (RecordType::TLSA, HickoryRData::TLSA(tlsa)) => Some(RecordData::TLSA {
886            cert_usage: u8::from(tlsa.cert_usage),
887            selector: u8::from(tlsa.selector),
888            matching: u8::from(tlsa.matching),
889            cert_data: hex_upper(&tlsa.cert_data),
890        }),
891        (RecordType::SSHFP, HickoryRData::SSHFP(sshfp)) => Some(RecordData::SSHFP {
892            algorithm: u8::from(sshfp.algorithm),
893            fingerprint_type: u8::from(sshfp.fingerprint_type),
894            fingerprint: hex_upper(&sshfp.fingerprint),
895        }),
896        // flags/services/regexp are DNS <character-string>s (raw bytes);
897        // they are conventionally ASCII, so a lossy decode is a faithful,
898        // panic-free rendering.
899        (RecordType::NAPTR, HickoryRData::NAPTR(naptr)) => Some(RecordData::NAPTR {
900            order: naptr.order,
901            preference: naptr.preference,
902            flags: String::from_utf8_lossy(&naptr.flags).into_owned(),
903            services: String::from_utf8_lossy(&naptr.services).into_owned(),
904            regexp: String::from_utf8_lossy(&naptr.regexp).into_owned(),
905            replacement: naptr.replacement.to_string(),
906        }),
907        _ => None,
908    }
909}
910
911/// Validates SRV service/protocol labels (alphanumeric and hyphens only, no dots)
912fn is_valid_srv_label(label: &str) -> bool {
913    !label.is_empty()
914        && label.len() <= 63
915        && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
916        && !label.starts_with('-')
917        && !label.ends_with('-')
918}
919
920#[cfg(test)]
921mod tests {
922    //! Unit tests for the pure helpers and public surface of the DNS
923    //! resolver, plus hermetic mock-server tests (see the `mock_*` tests
924    //! below) that exercise the full `resolve()` path against the shared
925    //! loopback UDP fixture in [`crate::dns::test_support`]. Live-network
926    //! variants remain `#[ignore]`d here and in the sibling modules
927    //! (`dns/dnssec.rs`, `dns/follow.rs`).
928
929    use super::*;
930
931    /// Regression: an intermittent 5-second stall on every default-resolver
932    /// lookup, and an outright `seer doctor` FAIL, on hosts that advertise an
933    /// IPv6 default route but have no working IPv6 transit (RA-advertised
934    /// IPv6 with no real transit is common on consumer networks).
935    ///
936    /// `GOOGLE` carries two IPv4 and two IPv6 addresses. hickory queries
937    /// `num_concurrent_reqs` (default 2) servers in parallel, and under the
938    /// default `QueryStatistics` ordering a short-lived CLI process has no
939    /// statistics to order by — so roughly one run in six drew both IPv6
940    /// servers, black-holed, and burned the full per-query timeout.
941    ///
942    /// `UserProvidedOrder` makes the first two picks deterministically the
943    /// IPv4 servers. IPv6 stays in the list for IPv6-only hosts, where the
944    /// IPv4 sends fail immediately with ENETUNREACH rather than timing out.
945    #[test]
946    fn standard_opts_pin_server_order_so_ipv4_is_tried_first() {
947        let mut opts = ResolverOpts::default();
948        apply_standard_opts(&mut opts, Duration::from_secs(5));
949        assert_eq!(
950            opts.server_ordering_strategy,
951            ServerOrderingStrategy::UserProvidedOrder,
952            "QueryStatistics ordering is nondeterministic in a short-lived \
953             process and can draw only black-holed IPv6 servers"
954        );
955    }
956
957    /// The ordering fix is only meaningful if the configured list actually
958    /// starts with IPv4 servers, and only sufficient if at least
959    /// `num_concurrent_reqs` of them are IPv4 — otherwise a parallel pair
960    /// still includes a black-holeable IPv6 server.
961    #[test]
962    fn default_config_lists_enough_ipv4_servers_before_any_ipv6() {
963        let config = ResolverConfig::udp_and_tcp(&GOOGLE);
964        let ips: Vec<_> = config.name_servers().iter().map(|ns| ns.ip).collect();
965        let concurrent = ResolverOpts::default().num_concurrent_reqs.max(1);
966        assert!(
967            ips.len() > concurrent,
968            "expected more servers than the concurrency window: {ips:?}"
969        );
970        for (i, ip) in ips.iter().take(concurrent).enumerate() {
971            assert!(
972                ip.is_ipv4(),
973                "server {i} in the concurrency window is not IPv4: {ips:?}"
974            );
975        }
976    }
977
978    #[test]
979    fn from_config_applies_dns_timeout() {
980        let mut config = crate::config::SeerConfig::default();
981        config.timeouts.dns_secs = 9;
982        let resolver = DnsResolver::from_config(&config);
983        assert_eq!(resolver.timeout, Duration::from_secs(9));
984    }
985    use std::net::{Ipv4Addr, Ipv6Addr};
986
987    // --- RecordType::from_str edge cases -----------------------------
988
989    #[test]
990    fn record_type_from_str_accepts_lowercase() {
991        assert_eq!(RecordType::from_str("a").unwrap(), RecordType::A);
992        assert_eq!(RecordType::from_str("mx").unwrap(), RecordType::MX);
993        assert_eq!(RecordType::from_str("cname").unwrap(), RecordType::CNAME);
994        assert_eq!(RecordType::from_str("dnskey").unwrap(), RecordType::DNSKEY);
995    }
996
997    #[test]
998    fn record_type_from_str_accepts_mixed_case() {
999        assert_eq!(RecordType::from_str("Mx").unwrap(), RecordType::MX);
1000        assert_eq!(RecordType::from_str("cNaMe").unwrap(), RecordType::CNAME);
1001    }
1002
1003    #[test]
1004    fn record_type_from_str_rejects_whitespace_padded() {
1005        // No trim is done inside from_str; leading/trailing whitespace
1006        // must currently cause a parse error so callers don't pass
1007        // malformed labels through.
1008        assert!(RecordType::from_str(" A").is_err());
1009        assert!(RecordType::from_str("A ").is_err());
1010        assert!(RecordType::from_str("\tA\n").is_err());
1011    }
1012
1013    #[test]
1014    fn record_type_from_str_rejects_unknown() {
1015        assert!(RecordType::from_str("NOTAREAL").is_err());
1016        assert!(RecordType::from_str("A1").is_err());
1017        assert!(RecordType::from_str("").is_err());
1018    }
1019
1020    #[test]
1021    fn record_type_from_str_accepts_star_as_any() {
1022        assert_eq!(RecordType::from_str("*").unwrap(), RecordType::ANY);
1023        assert_eq!(RecordType::from_str("ANY").unwrap(), RecordType::ANY);
1024        assert_eq!(RecordType::from_str("any").unwrap(), RecordType::ANY);
1025    }
1026
1027    // --- is_valid_srv_label ------------------------------------------
1028
1029    #[test]
1030    fn srv_label_accepts_alphanumeric_and_hyphen() {
1031        assert!(is_valid_srv_label("http"));
1032        assert!(is_valid_srv_label("ldap-tls"));
1033        assert!(is_valid_srv_label("a1"));
1034        assert!(is_valid_srv_label("tcp"));
1035    }
1036
1037    #[test]
1038    fn srv_label_rejects_empty() {
1039        assert!(!is_valid_srv_label(""));
1040    }
1041
1042    #[test]
1043    fn srv_label_rejects_leading_or_trailing_hyphen() {
1044        assert!(!is_valid_srv_label("-http"));
1045        assert!(!is_valid_srv_label("http-"));
1046        assert!(!is_valid_srv_label("-"));
1047    }
1048
1049    #[test]
1050    fn srv_label_rejects_dots() {
1051        // Dots would let an attacker construct `_service._tcp.evil.com.target`
1052        // and pivot the query to a different domain.
1053        assert!(!is_valid_srv_label("http.evil"));
1054        assert!(!is_valid_srv_label("a.b"));
1055    }
1056
1057    #[test]
1058    fn srv_label_rejects_special_chars() {
1059        assert!(!is_valid_srv_label("http evil"));
1060        assert!(!is_valid_srv_label("http/evil"));
1061        assert!(!is_valid_srv_label("http\0"));
1062        assert!(!is_valid_srv_label("http\n"));
1063    }
1064
1065    #[test]
1066    fn srv_label_rejects_over_63_chars() {
1067        let too_long = "a".repeat(64);
1068        assert!(!is_valid_srv_label(&too_long));
1069        let exactly_63 = "a".repeat(63);
1070        assert!(is_valid_srv_label(&exactly_63));
1071    }
1072
1073    // --- classify_ns_presence ----------------------------------------
1074
1075    #[test]
1076    fn classify_ns_presence_absent_on_empty_ok() {
1077        // resolve(.., NS) folds NXDOMAIN/NODATA into Ok(vec![]).
1078        let r: Result<Vec<DnsRecord>> = Ok(vec![]);
1079        assert_eq!(classify_ns_presence(&r), DnsPresence::Absent);
1080    }
1081
1082    #[test]
1083    fn classify_ns_presence_present_on_records() {
1084        let rec = DnsRecord {
1085            name: "example.test.".to_string(),
1086            record_type: RecordType::NS,
1087            ttl: 3600,
1088            data: RecordData::NS {
1089                nameserver: "ns1.example.net.".to_string(),
1090            },
1091        };
1092        let r: Result<Vec<DnsRecord>> = Ok(vec![rec]);
1093        assert_eq!(classify_ns_presence(&r), DnsPresence::Present);
1094    }
1095
1096    #[test]
1097    fn classify_ns_presence_unknown_on_error() {
1098        let r: Result<Vec<DnsRecord>> = Err(SeerError::DnsError("servfail".to_string()));
1099        assert_eq!(classify_ns_presence(&r), DnsPresence::Unknown);
1100    }
1101
1102    // --- reverse_dns_name --------------------------------------------
1103
1104    #[test]
1105    fn reverse_dns_name_formats_ipv4_correctly() {
1106        let ip: IpAddr = Ipv4Addr::new(192, 0, 2, 1).into();
1107        assert_eq!(reverse_dns_name(&ip), "1.2.0.192.in-addr.arpa");
1108    }
1109
1110    #[test]
1111    fn reverse_dns_name_formats_ipv6_correctly() {
1112        // ::1 (loopback) → 32 nibbles of 0 followed by ...0.0.0.1 reversed.
1113        let ip: IpAddr = Ipv6Addr::LOCALHOST.into();
1114        let name = reverse_dns_name(&ip);
1115        assert!(
1116            name.ends_with(".ip6.arpa"),
1117            "must end with .ip6.arpa; got: {}",
1118            name
1119        );
1120        // The first nibble (most-reversed position) must be 1 (from ::1 low bit).
1121        assert!(
1122            name.starts_with("1."),
1123            "expected '1.' prefix, got: {}",
1124            name
1125        );
1126        // 32 nibbles + 31 dots + ".ip6.arpa" (9 chars) = 72.
1127        assert_eq!(name.len(), 72);
1128    }
1129
1130    // --- DnsResolver construction ------------------------------------
1131
1132    #[test]
1133    fn resolver_new_has_default_timeout() {
1134        let r = DnsResolver::new();
1135        assert_eq!(r.timeout, DEFAULT_TIMEOUT);
1136    }
1137
1138    #[test]
1139    fn resolver_with_timeout_overrides_default() {
1140        let custom = Duration::from_secs(42);
1141        let r = DnsResolver::new().with_timeout(custom);
1142        assert_eq!(r.timeout, custom);
1143    }
1144
1145    #[test]
1146    fn resolver_default_matches_new() {
1147        let a = DnsResolver::default();
1148        let b = DnsResolver::new();
1149        assert_eq!(a.timeout, b.timeout);
1150    }
1151
1152    // --- create_custom_resolver validation ---------------------------
1153
1154    #[tokio::test]
1155    async fn custom_resolver_rejects_invalid_input() {
1156        // After hostname support was added, a string that is neither a
1157        // valid IP nor a resolvable hostname should fail with a clear
1158        // "failed to resolve" error rather than panicking or hanging.
1159        // We pick a name that is *syntactically* impossible to resolve.
1160        let r = DnsResolver::new();
1161        let err = r.create_custom_resolver("..").await.unwrap_err();
1162        let msg = err.to_string().to_lowercase();
1163        assert!(
1164            msg.contains("dns resolution failed") || msg.contains("invalid"),
1165            "expected resolution failure, got: {}",
1166            msg
1167        );
1168    }
1169
1170    #[tokio::test]
1171    async fn custom_resolver_rejects_private_ipv4() {
1172        // SSRF defense: private / reserved ranges must be blocked even
1173        // when passed as a literal IP rather than a hostname.
1174        let r = DnsResolver::new();
1175        for reserved in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.169.254"] {
1176            let err = r.create_custom_resolver(reserved).await.unwrap_err();
1177            let msg = err.to_string().to_lowercase();
1178            assert!(
1179                msg.contains("blocked") || msg.contains("reserved"),
1180                "reserved IP {} must be rejected, got error: {}",
1181                reserved,
1182                msg
1183            );
1184        }
1185    }
1186
1187    #[tokio::test]
1188    async fn custom_resolver_rejects_loopback_ipv6() {
1189        let r = DnsResolver::new();
1190        let err = r.create_custom_resolver("::1").await.unwrap_err();
1191        let msg = err.to_string().to_lowercase();
1192        assert!(
1193            msg.contains("blocked") || msg.contains("reserved"),
1194            "::1 must be rejected, got error: {}",
1195            msg
1196        );
1197    }
1198
1199    #[tokio::test]
1200    async fn custom_resolver_accepts_public_ipv4() {
1201        // A known public resolver IP must be acceptable.
1202        let r = DnsResolver::new();
1203        let result = r.create_custom_resolver("8.8.8.8").await;
1204        assert!(
1205            result.is_ok(),
1206            "8.8.8.8 must be accepted as a public nameserver, got: {:?}",
1207            result.err()
1208        );
1209    }
1210
1211    // --- DoT/DoH: SSRF refusal parity ---------------------------------
1212    //
1213    // The reserved-IP guard must apply identically to tls:// and https://
1214    // specs — an encrypted transport is not a bypass of the SSRF policy.
1215
1216    #[tokio::test]
1217    async fn custom_resolver_rejects_private_ip_for_dot_and_doh() {
1218        let r = DnsResolver::new();
1219        for reserved in [
1220            "tls://127.0.0.1",
1221            "tls://192.168.1.1:853",
1222            "tls://[::1]",
1223            "https://10.0.0.1/dns-query",
1224            "https://169.254.169.254",
1225            "tls://[fd00::1]:853",
1226        ] {
1227            let err = r.create_custom_resolver(reserved).await.unwrap_err();
1228            let msg = err.to_string().to_lowercase();
1229            assert!(
1230                msg.contains("blocked") || msg.contains("reserved"),
1231                "reserved spec {} must be rejected, got error: {}",
1232                reserved,
1233                msg
1234            );
1235        }
1236        // DoH to an IPv6 literal is refused even earlier, at parse time
1237        // (it can never work — see `NameserverSpec::parse`).
1238        let err = r
1239            .create_custom_resolver("https://[fd00::1]:443/dns-query")
1240            .await
1241            .unwrap_err();
1242        assert!(matches!(err, SeerError::InvalidInput(_)), "{err:?}");
1243    }
1244
1245    #[tokio::test]
1246    async fn custom_resolver_accepts_public_dot_and_doh_literals() {
1247        // Construction (parse → validate → config build) must succeed for
1248        // public DoT/DoH IP literals without any network traffic.
1249        let r = DnsResolver::new();
1250        for spec in ["tls://1.1.1.1", "https://8.8.8.8/dns-query"] {
1251            let result = r.create_custom_resolver(spec).await;
1252            assert!(
1253                result.is_ok(),
1254                "{} must be accepted, got: {:?}",
1255                spec,
1256                result.err()
1257            );
1258        }
1259    }
1260
1261    #[tokio::test]
1262    async fn custom_resolver_rejects_unknown_scheme() {
1263        let r = DnsResolver::new();
1264        let err = r.create_custom_resolver("ftp://8.8.8.8").await.unwrap_err();
1265        assert!(
1266            matches!(err, SeerError::InvalidInput(_)),
1267            "unknown scheme must be an input error, got: {err:?}"
1268        );
1269    }
1270
1271    // --- build_upstream_config: protocol/port/tls-name construction ----
1272
1273    fn spec(s: &str) -> NameserverSpec {
1274        NameserverSpec::parse(s).unwrap_or_else(|e| panic!("{s:?} must parse: {e}"))
1275    }
1276
1277    #[test]
1278    fn upstream_config_udp_defaults() {
1279        use hickory_resolver::config::ProtocolConfig;
1280
1281        let ip: IpAddr = "8.8.8.8".parse().unwrap();
1282        let config = build_upstream_config(&spec("8.8.8.8"), &[ip], None);
1283        let servers = config.name_servers();
1284        assert_eq!(servers.len(), 1);
1285        assert_eq!(servers[0].ip, ip);
1286        assert_eq!(servers[0].connections.len(), 1);
1287        assert_eq!(servers[0].connections[0].port, 53);
1288        assert!(matches!(
1289            servers[0].connections[0].protocol,
1290            ProtocolConfig::Udp
1291        ));
1292    }
1293
1294    #[test]
1295    fn upstream_config_udp_explicit_port() {
1296        let ip: IpAddr = "9.9.9.9".parse().unwrap();
1297        let config = build_upstream_config(&spec("9.9.9.9:5353"), &[ip], None);
1298        assert_eq!(config.name_servers()[0].connections[0].port, 5353);
1299    }
1300
1301    #[test]
1302    fn upstream_config_tls_sets_protocol_port_and_server_name() {
1303        use hickory_resolver::config::ProtocolConfig;
1304
1305        let ip: IpAddr = "9.9.9.9".parse().unwrap();
1306        let config = build_upstream_config(&spec("tls://dns.quad9.net"), &[ip], None);
1307        let ns = &config.name_servers()[0];
1308        assert_eq!(ns.ip, ip);
1309        assert_eq!(ns.connections.len(), 1);
1310        assert_eq!(ns.connections[0].port, 853);
1311        match &ns.connections[0].protocol {
1312            ProtocolConfig::Tls { server_name } => {
1313                assert_eq!(&**server_name, "dns.quad9.net");
1314            }
1315            other => panic!("expected Tls protocol, got {other:?}"),
1316        }
1317    }
1318
1319    #[test]
1320    fn upstream_config_tls_ip_literal_uses_ip_as_server_name() {
1321        use hickory_resolver::config::ProtocolConfig;
1322
1323        let ip: IpAddr = "1.1.1.1".parse().unwrap();
1324        let config = build_upstream_config(&spec("tls://1.1.1.1"), &[ip], None);
1325        match &config.name_servers()[0].connections[0].protocol {
1326            ProtocolConfig::Tls { server_name } => assert_eq!(&**server_name, "1.1.1.1"),
1327            other => panic!("expected Tls protocol, got {other:?}"),
1328        }
1329    }
1330
1331    #[test]
1332    fn upstream_config_https_sets_protocol_port_path_and_server_name() {
1333        use hickory_resolver::config::ProtocolConfig;
1334
1335        let ip: IpAddr = "104.16.248.249".parse().unwrap();
1336        let config = build_upstream_config(&spec("https://cloudflare-dns.com"), &[ip], None);
1337        let ns = &config.name_servers()[0];
1338        assert_eq!(ns.connections[0].port, 443);
1339        match &ns.connections[0].protocol {
1340            ProtocolConfig::Https { server_name, path } => {
1341                assert_eq!(&**server_name, "cloudflare-dns.com");
1342                assert_eq!(&**path, "/dns-query");
1343            }
1344            other => panic!("expected Https protocol, got {other:?}"),
1345        }
1346    }
1347
1348    #[test]
1349    fn upstream_config_https_custom_port_and_path() {
1350        use hickory_resolver::config::ProtocolConfig;
1351
1352        let ip: IpAddr = "8.8.8.8".parse().unwrap();
1353        let config = build_upstream_config(&spec("https://dns.google:8443/resolve"), &[ip], None);
1354        let ns = &config.name_servers()[0];
1355        assert_eq!(ns.connections[0].port, 8443);
1356        match &ns.connections[0].protocol {
1357            ProtocolConfig::Https { server_name, path } => {
1358                assert_eq!(&**server_name, "dns.google");
1359                assert_eq!(&**path, "/resolve");
1360            }
1361            other => panic!("expected Https protocol, got {other:?}"),
1362        }
1363    }
1364
1365    #[test]
1366    fn upstream_config_multiple_ips_share_spec() {
1367        // A hostname spec resolving to several addresses gets one upstream
1368        // per IP, all speaking the same protocol/port/TLS name.
1369        use hickory_resolver::config::ProtocolConfig;
1370
1371        let ips: Vec<IpAddr> = vec![
1372            "9.9.9.9".parse().unwrap(),
1373            "149.112.112.112".parse().unwrap(),
1374        ];
1375        let config = build_upstream_config(&spec("tls://dns.quad9.net"), &ips, None);
1376        let servers = config.name_servers();
1377        assert_eq!(servers.len(), 2);
1378        for (ns, expected_ip) in servers.iter().zip(&ips) {
1379            assert_eq!(&ns.ip, expected_ip);
1380            assert_eq!(ns.connections[0].port, 853);
1381            assert!(matches!(
1382                &ns.connections[0].protocol,
1383                ProtocolConfig::Tls { server_name } if &**server_name == "dns.quad9.net"
1384            ));
1385        }
1386    }
1387
1388    /// Regression: every dual-stack hostname nameserver (`dns.google`,
1389    /// `tls://one.one.one.one`, `https://cloudflare-dns.com/dns-query`) timed
1390    /// out on hosts with an IPv6 default route but no IPv6 transit, while IP
1391    /// literals worked. The bootstrap `lookup_ip` returns AAAA before A, the
1392    /// upstream list kept that order, and the pool's first parallel pair was
1393    /// both IPv6 — which spent the whole per-query deadline before any IPv4
1394    /// entry was tried.
1395    #[test]
1396    fn upstream_config_orders_ipv4_before_ipv6() {
1397        // As hickory's `lookup_ip` returns them: AAAA records first.
1398        let ips: Vec<IpAddr> = [
1399            "2606:4700::6810:f8f9",
1400            "2606:4700::6810:f9f9",
1401            "104.16.248.249",
1402            "104.16.249.249",
1403        ]
1404        .iter()
1405        .map(|s| s.parse().unwrap())
1406        .collect();
1407        let config =
1408            build_upstream_config(&spec("https://cloudflare-dns.com/dns-query"), &ips, None);
1409        let order: Vec<IpAddr> = config.name_servers().iter().map(|ns| ns.ip).collect();
1410        assert_eq!(
1411            order,
1412            [ips[2], ips[3], ips[0], ips[1]],
1413            "IPv4 first, each family in resolved order, nothing dropped"
1414        );
1415        let concurrent = ResolverOpts::default().num_concurrent_reqs.max(1);
1416        assert!(
1417            order.iter().take(concurrent).all(IpAddr::is_ipv4),
1418            "the parallel window must be all IPv4: {order:?}"
1419        );
1420    }
1421
1422    #[test]
1423    fn upstream_config_test_port_override_wins() {
1424        // The #[cfg(test)] mock-server seam must override the spec's port.
1425        let ip: IpAddr = "127.0.0.1".parse().unwrap();
1426        let config = build_upstream_config(&spec("127.0.0.1"), &[ip], Some(9999));
1427        assert_eq!(config.name_servers()[0].connections[0].port, 9999);
1428    }
1429
1430    // --- Live DoT/DoH queries (opt-in only) ----------------------------
1431
1432    #[tokio::test]
1433    #[ignore = "live network — DoT query against Cloudflare"]
1434    async fn live_resolve_over_dot() {
1435        let r = DnsResolver::new();
1436        let records = r
1437            .resolve("example.com", RecordType::A, Some("tls://1.1.1.1"))
1438            .await
1439            .expect("DoT lookup should succeed");
1440        assert!(!records.is_empty(), "expected A records over DoT");
1441    }
1442
1443    #[tokio::test]
1444    #[ignore = "live network — DoH query against Cloudflare"]
1445    async fn live_resolve_over_doh() {
1446        let r = DnsResolver::new();
1447        let records = r
1448            .resolve(
1449                "example.com",
1450                RecordType::A,
1451                Some("https://cloudflare-dns.com/dns-query"),
1452            )
1453            .await
1454            .expect("DoH lookup should succeed");
1455        assert!(!records.is_empty(), "expected A records over DoH");
1456    }
1457
1458    // --- SRV query validation (integration between helper + resolver) ----
1459
1460    #[tokio::test]
1461    async fn resolve_srv_rejects_invalid_service_label() {
1462        let r = DnsResolver::new();
1463        // With_dot service name would construct a malformed DNS query.
1464        let result = r
1465            .resolve_srv_core(&r.default_resolver, "http.evil", "tcp", "example.com")
1466            .await;
1467        assert!(result.is_err());
1468        let msg = result.unwrap_err().to_string().to_lowercase();
1469        assert!(
1470            msg.contains("invalid srv service"),
1471            "expected SRV service validation error, got: {}",
1472            msg
1473        );
1474    }
1475
1476    #[tokio::test]
1477    async fn resolve_srv_rejects_invalid_protocol_label() {
1478        let r = DnsResolver::new();
1479        let result = r
1480            .resolve_srv_core(&r.default_resolver, "http", "tcp.evil", "example.com")
1481            .await;
1482        assert!(result.is_err());
1483        let msg = result.unwrap_err().to_string().to_lowercase();
1484        assert!(
1485            msg.contains("invalid srv protocol"),
1486            "expected SRV protocol validation error, got: {}",
1487            msg
1488        );
1489    }
1490
1491    #[tokio::test]
1492    async fn resolve_srv_normalizes_and_validates_domain_input() {
1493        // SRV names must be normalized/validated like every other query, or
1494        // garbage input reaches query construction as a (misclassified) DNS
1495        // failure instead of an input error (2026-07-11 review).
1496        let r = DnsResolver::new();
1497        let result = r
1498            .resolve("_http._tcp.not a valid domain", RecordType::SRV, None)
1499            .await;
1500        assert!(
1501            matches!(result, Err(SeerError::InvalidDomain(_))),
1502            "expected InvalidDomain from domain validation, got: {result:?}"
1503        );
1504    }
1505
1506    // --- Normalization applied before resolution ---------------------
1507
1508    #[tokio::test]
1509    async fn resolve_normalizes_uppercase_domain_input() {
1510        // We can't hit the network in unit tests, but we can at least
1511        // assert that normalization rejects clearly-invalid input
1512        // before any network call is made. Domains with a leading `.`
1513        // are rejected by the normalizer.
1514        let r = DnsResolver::new();
1515        let result = r.resolve(".bad.example", RecordType::A, None).await;
1516        assert!(result.is_err(), "leading-dot domain must be rejected");
1517    }
1518
1519    // --- SRV record -------------------------------------------------
1520
1521    // --- SRV via dig-style names (parse_srv_query) -------------------
1522
1523    #[test]
1524    fn parse_srv_query_extracts_service_proto_and_name() {
1525        assert_eq!(
1526            parse_srv_query("_sip._tcp.example.com"),
1527            Some((
1528                "sip".to_string(),
1529                "tcp".to_string(),
1530                "example.com".to_string()
1531            ))
1532        );
1533    }
1534
1535    #[test]
1536    fn parse_srv_query_keeps_multilabel_domain() {
1537        assert_eq!(
1538            parse_srv_query("_sip._tcp.sip.voice.google.com"),
1539            Some((
1540                "sip".to_string(),
1541                "tcp".to_string(),
1542                "sip.voice.google.com".to_string()
1543            ))
1544        );
1545    }
1546
1547    #[test]
1548    fn fqdn_appends_root_dot_once() {
1549        assert_eq!(fqdn("example.com"), "example.com.");
1550        assert_eq!(fqdn("example.com."), "example.com.");
1551    }
1552
1553    #[test]
1554    fn parse_srv_query_rejects_bare_domain() {
1555        assert_eq!(parse_srv_query("example.com"), None);
1556    }
1557
1558    #[test]
1559    fn parse_srv_query_rejects_missing_proto_label() {
1560        // Second label must be an `_proto` label.
1561        assert_eq!(parse_srv_query("_sip.example.com"), None);
1562    }
1563
1564    #[tokio::test]
1565    async fn resolve_rejects_bare_domain_for_srv_as_input_error() {
1566        // A bare domain (no _service._proto labels) cannot be an SRV query.
1567        // This is a usage/input error — NOT a transient DNS failure — so it
1568        // must surface as InvalidInput (which maps to a permanent, non-retryable
1569        // signal across the Python/MCP boundary), and still carry the hint.
1570        let r = DnsResolver::new();
1571        let err = r
1572            .resolve("example.com", RecordType::SRV, None)
1573            .await
1574            .expect_err("bare-domain SRV must error");
1575        assert!(
1576            matches!(err, SeerError::InvalidInput(_)),
1577            "bare-domain SRV should be an input error, got: {err:?}"
1578        );
1579        assert!(err.to_string().contains("_service._proto"));
1580    }
1581
1582    #[tokio::test]
1583    #[ignore = "live network"]
1584    async fn resolve_srv_via_dig_style_name_returns_records() {
1585        // _caldavs._tcp.google.com is a long-standing public SRV record
1586        // (CalDAV discovery → calendar.google.com:443).
1587        let r = DnsResolver::new();
1588        let records = r
1589            .resolve("_caldavs._tcp.google.com", RecordType::SRV, None)
1590            .await
1591            .expect("dig-style SRV lookup should succeed");
1592        assert!(!records.is_empty(), "expected SRV records");
1593        assert!(records.iter().all(|r| r.record_type == RecordType::SRV));
1594    }
1595
1596    #[tokio::test]
1597    #[ignore = "live network"]
1598    async fn resolve_naptr_returns_records() {
1599        // sip2sip.info publishes stable NAPTR records for SIP discovery.
1600        let r = DnsResolver::new();
1601        let records = r
1602            .resolve("sip2sip.info", RecordType::NAPTR, None)
1603            .await
1604            .expect("NAPTR lookup should succeed");
1605        assert!(!records.is_empty(), "expected NAPTR records");
1606        assert!(records.iter().all(|r| r.record_type == RecordType::NAPTR));
1607    }
1608
1609    // --- prepare_query: PTR must accept raw IP literals (incl. IPv6) --
1610
1611    #[test]
1612    fn prepare_query_passes_ipv6_literal_through_for_ptr() {
1613        // Regression: normalize_domain's port-strip heuristic mangled IPv6
1614        // literals (the trailing `:1111` group looks like a `:port`), so IPv6
1615        // reverse lookups failed with "Invalid domain name" before ever
1616        // reaching resolve_ptr. PTR queries for IP literals must bypass domain
1617        // normalization.
1618        let out = prepare_query("2606:4700:4700::1111", RecordType::PTR).unwrap();
1619        assert_eq!(out, "2606:4700:4700::1111");
1620    }
1621
1622    #[test]
1623    fn prepare_query_passes_ipv6_loopback_through_for_ptr() {
1624        let out = prepare_query("::1", RecordType::PTR).unwrap();
1625        assert_eq!(out, "::1");
1626    }
1627
1628    #[test]
1629    fn prepare_query_passes_ipv4_literal_through_for_ptr() {
1630        let out = prepare_query("8.8.8.8", RecordType::PTR).unwrap();
1631        assert_eq!(out, "8.8.8.8");
1632    }
1633
1634    #[test]
1635    fn prepare_query_normalizes_non_ip_ptr_names() {
1636        // A reverse-DNS name (not an IP literal) still gets normalized.
1637        let out = prepare_query("1.1.1.1.in-addr.arpa", RecordType::PTR).unwrap();
1638        assert_eq!(out, "1.1.1.1.in-addr.arpa");
1639    }
1640
1641    #[test]
1642    fn prepare_query_keeps_www_for_record_queries() {
1643        // Regression: record queries normalized with `normalize_domain`,
1644        // which strips `www.` — so `dig www.example.com CNAME` silently
1645        // queried the apex. A record query is about the exact name.
1646        let out = prepare_query("HTTPS://WWW.Example.com/path", RecordType::A).unwrap();
1647        assert_eq!(out, "www.example.com");
1648        let out = prepare_query("www.example.com", RecordType::CNAME).unwrap();
1649        assert_eq!(out, "www.example.com");
1650    }
1651
1652    #[test]
1653    fn prepare_query_still_normalizes_scheme_port_path_and_case() {
1654        let out = prepare_query("https://API.Example.COM:8443/v1?q=1#frag", RecordType::A).unwrap();
1655        assert_eq!(out, "api.example.com");
1656        let out = prepare_query("example.com.", RecordType::MX).unwrap();
1657        assert_eq!(out, "example.com");
1658        assert!(prepare_query("not a domain", RecordType::A).is_err());
1659    }
1660
1661    #[test]
1662    fn prepare_query_runs_ptr_ip_literal_through_the_normalizer() {
1663        // Regression: an IP literal skipped the normalizer entirely, and with
1664        // it the `SEER_DOMAIN_ALLOWLIST` check that lives inside — so a PTR
1665        // query for an IP bypassed the allowlist that the equivalent
1666        // `in-addr.arpa` query obeys. Simulate an allowlist that excludes
1667        // the reverse zones with a rejecting normalizer.
1668        let deny_arpa = |name: &str| -> Result<String> {
1669            if name.ends_with(".arpa") {
1670                Err(SeerError::DomainNotAllowed {
1671                    domain: name.to_string(),
1672                    tld: "arpa".to_string(),
1673                })
1674            } else {
1675                Ok(name.to_string())
1676            }
1677        };
1678        for ip in ["192.0.2.1", "2606:4700:4700::1111"] {
1679            let err = prepare_query_with(ip, RecordType::PTR, deny_arpa)
1680                .expect_err("PTR for an IP literal must obey the normalizer");
1681            assert!(matches!(err, SeerError::DomainNotAllowed { .. }), "{err:?}");
1682        }
1683        // With the real normalizer (no allowlist in tests) both still pass.
1684        assert_eq!(
1685            prepare_query_with("192.0.2.1", RecordType::PTR, normalize_host).unwrap(),
1686            "192.0.2.1"
1687        );
1688    }
1689
1690    #[test]
1691    fn parse_caa_keeps_reserved_flag_bits() {
1692        // Regression: flags were rebuilt from `issuer_critical` alone, so a
1693        // record published with reserved bits set reported flags=128/0.
1694        let mut caa = CAA::new_issue(
1695            true,
1696            Some(hickory_resolver::proto::rr::Name::from_ascii("letsencrypt.org").unwrap()),
1697            vec![],
1698        );
1699        caa.reserved_flags = 0x01;
1700        let (flags, tag, value) = parse_caa(&caa);
1701        assert_eq!(flags, 0x81);
1702        assert_eq!(tag, "issue");
1703        assert_eq!(value, "letsencrypt.org");
1704    }
1705
1706    // --- Hermetic mock-server tests -----------------------------------
1707    //
1708    // These exercise the full resolve() path (normalization →
1709    // custom-resolver construction → hickory transport → RData conversion)
1710    // against the shared loopback fixture in `crate::dns::test_support`,
1711    // without touching the network. The SSRF guards deliberately refuse
1712    // loopback, so the resolver under test uses the `#[cfg(test)]`-only
1713    // `allowing_private_hosts` / `with_port` seams, which do not exist in
1714    // release builds.
1715
1716    use crate::dns::test_support::{
1717        mock_dns_resolver, mock_dns_resolver_default, spawn_mock_dns, spawn_mock_dns_fn, MockMode,
1718        MockReply,
1719    };
1720
1721    async fn mock_zone_lookup(record_type: RecordType, domain: &str) -> Vec<DnsRecord> {
1722        let port = spawn_mock_dns(MockMode::Zone).await;
1723        mock_dns_resolver(port)
1724            .resolve(domain, record_type, Some("127.0.0.1"))
1725            .await
1726            .unwrap_or_else(|e| panic!("{record_type} lookup against mock must succeed: {e}"))
1727    }
1728
1729    #[tokio::test]
1730    async fn mock_resolve_a_returns_addresses() {
1731        let records = mock_zone_lookup(RecordType::A, "seer.test").await;
1732        assert_eq!(records.len(), 2);
1733        assert!(records.iter().all(|r| r.record_type == RecordType::A));
1734        assert_eq!(records[0].name, "seer.test");
1735        assert_eq!(records[0].ttl, 300);
1736        let addresses: Vec<String> = records
1737            .iter()
1738            .map(|r| match &r.data {
1739                RecordData::A { address } => address.clone(),
1740                other => panic!("expected A data, got {other:?}"),
1741            })
1742            .collect();
1743        assert!(addresses.contains(&"192.0.2.1".to_string()));
1744        assert!(addresses.contains(&"192.0.2.2".to_string()));
1745    }
1746
1747    #[tokio::test]
1748    async fn mock_resolve_mx_sorts_by_preference() {
1749        let records = mock_zone_lookup(RecordType::MX, "seer.test").await;
1750        let prefs: Vec<u16> = records
1751            .iter()
1752            .map(|r| match &r.data {
1753                RecordData::MX { preference, .. } => *preference,
1754                other => panic!("expected MX data, got {other:?}"),
1755            })
1756            .collect();
1757        // The zone serves 30, 10, 20 — resolve() must sort ascending.
1758        assert_eq!(prefs, vec![10, 20, 30]);
1759        assert!(matches!(
1760            &records[0].data,
1761            RecordData::MX { exchange, .. } if exchange == "a.mail.seer.test."
1762        ));
1763    }
1764
1765    #[tokio::test]
1766    async fn mock_resolve_txt_joins_character_strings() {
1767        let records = mock_zone_lookup(RecordType::TXT, "seer.test").await;
1768        assert_eq!(records.len(), 1);
1769        match &records[0].data {
1770            RecordData::TXT { text } => assert_eq!(text, "v=spf1 -all"),
1771            other => panic!("expected TXT data, got {other:?}"),
1772        }
1773    }
1774
1775    #[tokio::test]
1776    async fn mock_resolve_soa_maps_all_fields() {
1777        let records = mock_zone_lookup(RecordType::SOA, "seer.test").await;
1778        assert_eq!(records.len(), 1);
1779        match &records[0].data {
1780            RecordData::SOA {
1781                mname,
1782                rname,
1783                serial,
1784                refresh,
1785                retry,
1786                expire,
1787                minimum,
1788            } => {
1789                assert_eq!(mname, "ns1.seer.test.");
1790                assert_eq!(rname, "hostmaster.seer.test.");
1791                assert_eq!(*serial, 2026070101);
1792                assert_eq!(*refresh, 7200);
1793                assert_eq!(*retry, 3600);
1794                assert_eq!(*expire, 1209600);
1795                assert_eq!(*minimum, 300);
1796            }
1797            other => panic!("expected SOA data, got {other:?}"),
1798        }
1799    }
1800
1801    #[tokio::test]
1802    async fn mock_resolve_caa_maps_flags_tag_and_value() {
1803        let records = mock_zone_lookup(RecordType::CAA, "seer.test").await;
1804        assert_eq!(records.len(), 2);
1805        let by_tag = |wanted: &str| {
1806            records
1807                .iter()
1808                .find_map(|r| match &r.data {
1809                    RecordData::CAA { flags, tag, value } if tag == wanted => {
1810                        Some((*flags, value.clone()))
1811                    }
1812                    _ => None,
1813                })
1814                .unwrap_or_else(|| panic!("expected a CAA record with tag {wanted}"))
1815        };
1816        // issuer_critical=false → flags 0; true → 128 (RFC 8659 critical bit).
1817        assert_eq!(by_tag("issue"), (0, "letsencrypt.org".to_string()));
1818        assert_eq!(
1819            by_tag("iodef"),
1820            (128, "mailto:security@seer.test".to_string())
1821        );
1822    }
1823
1824    #[tokio::test]
1825    async fn mock_resolve_tlsa_hex_encodes_cert_data() {
1826        let records = mock_zone_lookup(RecordType::TLSA, "_443._tcp.seer.test").await;
1827        assert_eq!(records.len(), 1);
1828        match &records[0].data {
1829            RecordData::TLSA {
1830                cert_usage,
1831                selector,
1832                matching,
1833                cert_data,
1834            } => {
1835                assert_eq!((*cert_usage, *selector, *matching), (3, 1, 1));
1836                assert_eq!(cert_data, "ABCD01");
1837            }
1838            other => panic!("expected TLSA data, got {other:?}"),
1839        }
1840    }
1841
1842    #[tokio::test]
1843    async fn mock_resolve_sshfp_hex_encodes_fingerprint() {
1844        let records = mock_zone_lookup(RecordType::SSHFP, "seer.test").await;
1845        assert_eq!(records.len(), 1);
1846        match &records[0].data {
1847            RecordData::SSHFP {
1848                algorithm,
1849                fingerprint_type,
1850                fingerprint,
1851            } => {
1852                assert_eq!((*algorithm, *fingerprint_type), (4, 2));
1853                assert_eq!(fingerprint, "DEADBEEF");
1854            }
1855            other => panic!("expected SSHFP data, got {other:?}"),
1856        }
1857    }
1858
1859    #[tokio::test]
1860    async fn mock_resolve_naptr_decodes_character_strings() {
1861        let records = mock_zone_lookup(RecordType::NAPTR, "seer.test").await;
1862        assert_eq!(records.len(), 1);
1863        match &records[0].data {
1864            RecordData::NAPTR {
1865                order,
1866                preference,
1867                flags,
1868                services,
1869                regexp,
1870                replacement,
1871            } => {
1872                assert_eq!((*order, *preference), (100, 50));
1873                assert_eq!(flags, "U");
1874                assert_eq!(services, "E2U+sip");
1875                assert_eq!(regexp, "!^.*$!sip:info@seer.test!");
1876                assert_eq!(replacement, ".");
1877            }
1878            other => panic!("expected NAPTR data, got {other:?}"),
1879        }
1880    }
1881
1882    #[tokio::test]
1883    async fn mock_resolve_srv_via_dig_style_name() {
1884        let records = mock_zone_lookup(RecordType::SRV, "_sip._tcp.seer.test").await;
1885        assert_eq!(records.len(), 1);
1886        assert_eq!(records[0].name, "_sip._tcp.seer.test");
1887        match &records[0].data {
1888            RecordData::SRV {
1889                priority,
1890                weight,
1891                port,
1892                target,
1893            } => {
1894                assert_eq!((*priority, *weight, *port), (10, 5, 5060));
1895                assert_eq!(target, "sipserver.seer.test.");
1896            }
1897            other => panic!("expected SRV data, got {other:?}"),
1898        }
1899    }
1900
1901    #[tokio::test]
1902    async fn mock_resolve_ptr_transforms_ip_literal() {
1903        let records = mock_zone_lookup(RecordType::PTR, "192.0.2.1").await;
1904        assert_eq!(records.len(), 1);
1905        // The record is reported under the reverse-DNS name, not the raw IP.
1906        assert_eq!(records[0].name, "1.2.0.192.in-addr.arpa");
1907        assert!(matches!(
1908            &records[0].data,
1909            RecordData::PTR { target } if target == "ptr.seer.test."
1910        ));
1911    }
1912
1913    #[tokio::test]
1914    async fn mock_resolve_keeps_www_label() {
1915        // Regression: `resolve("www.x", CNAME)` queried `x` (normalize_domain
1916        // strips `www.`), which has no CNAME — an empty, wrong answer.
1917        let records = mock_zone_lookup(RecordType::CNAME, "www.seer.test").await;
1918        assert_eq!(records.len(), 1, "{records:?}");
1919        assert_eq!(records[0].name, "www.seer.test");
1920        assert_eq!(records[0].data.to_string(), "edge.cdn.test.");
1921    }
1922
1923    #[tokio::test]
1924    async fn mock_resolve_srv_keeps_www_label() {
1925        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1926            ("_sip._tcp.www.seer.test", HickoryRecordType::SRV) => {
1927                MockReply::Answer(vec![HickoryRData::SRV(
1928                    hickory_resolver::proto::rr::rdata::SRV::new(
1929                        10,
1930                        5,
1931                        5060,
1932                        hickory_resolver::proto::rr::Name::from_ascii("sip.seer.test.").unwrap(),
1933                    ),
1934                )])
1935            }
1936            _ => MockReply::NoData,
1937        })
1938        .await;
1939        let records = mock_dns_resolver(port)
1940            .resolve(
1941                "_sip._tcp.www.seer.test",
1942                RecordType::SRV,
1943                Some("127.0.0.1"),
1944            )
1945            .await
1946            .expect("SRV against mock");
1947        assert_eq!(records.len(), 1, "{records:?}");
1948        assert_eq!(records[0].name, "_sip._tcp.www.seer.test");
1949    }
1950
1951    #[tokio::test]
1952    async fn mock_presence_still_probes_the_apex_for_www_input() {
1953        // `presence` answers a registration-level question, so it must keep
1954        // stripping `www.` even though `resolve` no longer does.
1955        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1956            ("seer.test", HickoryRecordType::NS) => MockReply::Answer(vec![HickoryRData::NS(
1957                hickory_resolver::proto::rr::rdata::NS(
1958                    hickory_resolver::proto::rr::Name::from_ascii("ns1.seer.test.").unwrap(),
1959                ),
1960            )]),
1961            // What a recursive resolver relays for a name inside the zone:
1962            // querying `www` itself would read as "no NS" → Absent.
1963            ("www.seer.test", HickoryRecordType::NS) => MockReply::NoDataWithSoa("seer.test"),
1964            _ => MockReply::NxDomain,
1965        })
1966        .await;
1967        let resolver = mock_dns_resolver_default(port);
1968        assert_eq!(
1969            resolver.presence("www.seer.test").await,
1970            DnsPresence::Present
1971        );
1972    }
1973
1974    #[tokio::test]
1975    async fn mock_resolve_ptr_transforms_ipv6_literal() {
1976        let records = mock_zone_lookup(RecordType::PTR, "2606:4700:4700::1111").await;
1977        assert_eq!(records.len(), 1, "{records:?}");
1978        assert_eq!(records[0].data.to_string(), "one.one.one.one.");
1979    }
1980
1981    #[tokio::test]
1982    async fn mock_resolve_any_aggregates_multiple_types() {
1983        let records = mock_zone_lookup(RecordType::ANY, "seer.test").await;
1984        // resolve_any fans out to exactly A/AAAA/MX/NS/TXT/SOA/CAA.
1985        for expected in [
1986            RecordType::A,
1987            RecordType::AAAA,
1988            RecordType::MX,
1989            RecordType::NS,
1990            RecordType::TXT,
1991            RecordType::SOA,
1992            RecordType::CAA,
1993        ] {
1994            assert!(
1995                records.iter().any(|r| r.record_type == expected),
1996                "ANY must include {expected} records"
1997            );
1998        }
1999        // 2 A + 1 AAAA + 3 MX + 1 NS + 1 TXT + 1 SOA + 2 CAA = 11
2000        assert_eq!(records.len(), 11);
2001    }
2002
2003    #[tokio::test]
2004    async fn mock_nodata_folds_to_empty_and_classifies_absent() {
2005        let port = spawn_mock_dns(MockMode::NoData).await;
2006        let result = mock_dns_resolver(port)
2007            .resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
2008            .await;
2009        assert!(
2010            matches!(&result, Ok(records) if records.is_empty()),
2011            "NODATA must fold to Ok(vec![]), got: {result:?}"
2012        );
2013        assert_eq!(classify_ns_presence(&result), DnsPresence::Absent);
2014    }
2015
2016    #[tokio::test]
2017    async fn mock_nxdomain_folds_to_empty_and_classifies_absent() {
2018        let port = spawn_mock_dns(MockMode::Nxdomain).await;
2019        let result = mock_dns_resolver(port)
2020            .resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
2021            .await;
2022        assert!(
2023            matches!(&result, Ok(records) if records.is_empty()),
2024            "NXDOMAIN must fold to Ok(vec![]), got: {result:?}"
2025        );
2026        assert_eq!(classify_ns_presence(&result), DnsPresence::Absent);
2027    }
2028
2029    #[tokio::test]
2030    async fn mock_timeout_errors_and_classifies_unknown() {
2031        let port = spawn_mock_dns(MockMode::Ignore).await;
2032        // Short timeout keeps the test fast: 2 attempts × 200ms ≈ 400ms.
2033        let resolver = DnsResolver::new()
2034            .with_timeout(Duration::from_millis(200))
2035            .allowing_private_hosts()
2036            .with_port(port);
2037        let result = resolver
2038            .resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
2039            .await;
2040        match &result {
2041            Err(SeerError::DnsError(_)) => {}
2042            other => panic!("unanswered query must surface a DnsError, got: {other:?}"),
2043        }
2044        assert_eq!(classify_ns_presence(&result), DnsPresence::Unknown);
2045    }
2046
2047    #[tokio::test]
2048    async fn resolve_type_rejects_composite_types_consistently() {
2049        // SRV and ANY are composite queries owned by resolve(); the shared
2050        // dispatch must reject them identically for every entry point. The
2051        // rejection happens before any I/O, so this test never contacts a
2052        // server despite using the default resolver.
2053        let r = DnsResolver::new();
2054        for composite in [RecordType::SRV, RecordType::ANY] {
2055            let err = r
2056                .resolve_type(&r.default_resolver, "seer.test", composite)
2057                .await
2058                .expect_err("composite types must be rejected by resolve_type");
2059            assert_eq!(
2060                err.to_string(),
2061                unsupported_record_type(composite).to_string()
2062            );
2063        }
2064    }
2065}