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