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