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