Skip to main content

seer_core/dns/
resolver.rs

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