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::time::Duration;
14
15use hickory_resolver::config::{NameServerConfig, ResolveHosts, ResolverConfig, GOOGLE};
16use hickory_resolver::net::runtime::TokioRuntimeProvider;
17use hickory_resolver::net::NetError;
18use hickory_resolver::proto::dnssec::PublicKey;
19use hickory_resolver::proto::rr::rdata::CAA;
20use hickory_resolver::proto::rr::{RData as HickoryRData, RecordType as HickoryRecordType};
21use hickory_resolver::TokioResolver;
22use tracing::{debug, instrument};
23
24use super::records::{DnsRecord, RecordData, RecordType};
25use crate::error::{Result, SeerError};
26use crate::validation::normalize_domain;
27
28/// Convert a DNS lookup result, treating "no records found" as an empty vec
29/// rather than an error. This is correct DNS behavior — the absence of a
30/// record type for a domain is a valid response (NODATA), not a failure.
31fn dns_lookup_or_empty<T>(
32    result: std::result::Result<T, NetError>,
33    record_type: &str,
34) -> Result<Option<T>> {
35    match result {
36        Ok(response) => Ok(Some(response)),
37        Err(e) if e.is_no_records_found() => Ok(None),
38        Err(e) => Err(SeerError::DnsError(format!(
39            "{} lookup failed: {}",
40            record_type, e
41        ))),
42    }
43}
44
45/// Default timeout for DNS queries (5 seconds).
46/// DNS is typically fast; longer timeouts indicate network issues or unreachable servers.
47const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
48
49/// Build a TokioResolver pre-configured with the given upstream config and
50/// our standard options (timeout, retries, no hosts-file consultation).
51///
52/// Build only fails when TLS configuration construction fails; we don't
53/// enable TLS features in seer-core so `expect` is safe here and is the
54/// cleanest expression of that invariant.
55fn build_resolver(config: ResolverConfig, timeout: Duration) -> TokioResolver {
56    let mut builder = TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
57    {
58        let opts = builder.options_mut();
59        opts.timeout = timeout;
60        opts.attempts = 2;
61        opts.use_hosts_file = ResolveHosts::Never;
62    }
63    builder
64        .build()
65        .expect("hickory resolver build is infallible without TLS features")
66}
67
68/// DNS resolver for querying various record types.
69///
70/// Uses Google DNS (8.8.8.8) by default, but supports custom nameservers.
71/// The default resolver is cached and reused across queries to avoid
72/// repeated initialization overhead.
73#[derive(Clone)]
74pub struct DnsResolver {
75    timeout: Duration,
76    /// Cached default resolver (Google DNS). Reused across all queries
77    /// that don't specify a custom nameserver.
78    default_resolver: TokioResolver,
79}
80
81impl std::fmt::Debug for DnsResolver {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("DnsResolver")
84            .field("timeout", &self.timeout)
85            .finish()
86    }
87}
88
89impl Default for DnsResolver {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95impl DnsResolver {
96    /// Creates a new DNS resolver with default settings.
97    pub fn new() -> Self {
98        Self {
99            timeout: DEFAULT_TIMEOUT,
100            default_resolver: build_resolver(ResolverConfig::udp_and_tcp(&GOOGLE), DEFAULT_TIMEOUT),
101        }
102    }
103
104    /// Sets the timeout for DNS queries.
105    ///
106    /// The default is 5 seconds, which is sufficient for most DNS queries.
107    pub fn with_timeout(mut self, timeout: Duration) -> Self {
108        self.timeout = timeout;
109        self.default_resolver = build_resolver(ResolverConfig::udp_and_tcp(&GOOGLE), timeout);
110        self
111    }
112
113    async fn create_custom_resolver(&self, nameserver: &str) -> Result<TokioResolver> {
114        // Accept either a literal IP or a hostname. For hostnames, resolve
115        // via the default (Google DNS) hickory resolver so we do not depend
116        // on the OS resolver — that is the same fallback principle as the
117        // SSL probe fix: when the local system resolver is broken (split
118        // DNS, broken router, container netns), hickory still reaches the
119        // public name servers and the user-supplied authoritative server
120        // is still usable.
121        let ips: Vec<IpAddr> = if let Ok(ip) = nameserver.parse::<IpAddr>() {
122            vec![ip]
123        } else {
124            let response = self
125                .default_resolver
126                .lookup_ip(nameserver)
127                .await
128                .map_err(|e| {
129                    SeerError::DnsError(format!(
130                        "failed to resolve nameserver hostname {}: {}",
131                        nameserver, e
132                    ))
133                })?;
134            let resolved: Vec<IpAddr> = response.iter().collect();
135            if resolved.is_empty() {
136                return Err(SeerError::DnsError(format!(
137                    "nameserver {} did not resolve to any addresses",
138                    nameserver
139                )));
140            }
141            resolved
142        };
143
144        // SSRF protection: reject private/reserved IPs — whether supplied
145        // literally or returned by name resolution. Without this, a
146        // hostname under attacker control could point at internal infra.
147        for ip in &ips {
148            if let Some(reason) = crate::validation::describe_reserved_ip(ip) {
149                return Err(SeerError::DnsError(format!(
150                    "nameserver {} blocked: {}",
151                    nameserver, reason
152                )));
153            }
154        }
155
156        // Build a config with all resolved IPs as upstream nameservers.
157        // In hickory 0.26, NameServerConfig::udp(IpAddr) builds a
158        // ConnectionConfig with the default DNS port (53) for us, so we
159        // no longer need to construct a SocketAddr explicitly.
160        let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
161        for ip in ips {
162            config.add_name_server(NameServerConfig::udp(ip));
163        }
164
165        Ok(build_resolver(config, self.timeout))
166    }
167
168    /// Resolves DNS records for a domain.
169    ///
170    /// # Arguments
171    /// * `domain` - The domain name to query
172    /// * `record_type` - The type of DNS record to look up (A, AAAA, MX, etc.)
173    /// * `nameserver` - Optional custom nameserver IP; uses Google DNS if None
174    #[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
175    pub async fn resolve(
176        &self,
177        domain: &str,
178        record_type: RecordType,
179        nameserver: Option<&str>,
180    ) -> Result<Vec<DnsRecord>> {
181        // Reuse the cached default resolver when no custom nameserver is specified
182        let custom_resolver;
183        let resolver = if let Some(ns) = nameserver {
184            custom_resolver = self.create_custom_resolver(ns).await?;
185            &custom_resolver
186        } else {
187            &self.default_resolver
188        };
189        let domain = prepare_query(domain, record_type)?;
190
191        debug!(nameserver = nameserver.unwrap_or("system"), "Resolving DNS");
192
193        match record_type {
194            RecordType::A => self.resolve_a(resolver, &domain).await,
195            RecordType::AAAA => self.resolve_aaaa(resolver, &domain).await,
196            RecordType::CNAME => self.resolve_cname(resolver, &domain).await,
197            RecordType::MX => self.resolve_mx(resolver, &domain).await,
198            RecordType::NS => self.resolve_ns(resolver, &domain).await,
199            RecordType::TXT => self.resolve_txt(resolver, &domain).await,
200            RecordType::SOA => self.resolve_soa(resolver, &domain).await,
201            RecordType::PTR => self.resolve_ptr(resolver, &domain).await,
202            RecordType::SRV => match parse_srv_query(&domain) {
203                // dig-style `_service._proto.name` queries resolve directly.
204                Some((service, protocol, name)) => {
205                    self.resolve_srv_core(resolver, &service, &protocol, &name)
206                        .await
207                }
208                // A bare domain isn't a valid SRV query — surface a usage hint
209                // as an input error (permanent), not a transient DNS failure.
210                None => Err(SeerError::InvalidInput(
211                    "SRV records require service name format: _service._proto.name".to_string(),
212                )),
213            },
214            RecordType::CAA => self.resolve_caa(resolver, &domain).await,
215            RecordType::DNSKEY => self.resolve_dnskey(resolver, &domain).await,
216            RecordType::DS => self.resolve_ds(resolver, &domain).await,
217            RecordType::TLSA => self.resolve_tlsa(resolver, &domain).await,
218            RecordType::SSHFP => self.resolve_sshfp(resolver, &domain).await,
219            RecordType::NAPTR => self.resolve_naptr(resolver, &domain).await,
220            RecordType::ANY => self.resolve_any(resolver, &domain).await,
221        }
222    }
223
224    /// Resolves SRV records for a service.
225    ///
226    /// # Arguments
227    /// * `service` - The service name (e.g., "http", "ldap")
228    /// * `protocol` - The protocol (e.g., "tcp", "udp")
229    /// * `domain` - The domain name
230    /// * `nameserver` - Optional custom nameserver IP
231    #[instrument(skip(self), fields(domain = %domain, service = %service, protocol = %protocol))]
232    pub async fn resolve_srv(
233        &self,
234        service: &str,
235        protocol: &str,
236        domain: &str,
237        nameserver: Option<&str>,
238    ) -> Result<Vec<DnsRecord>> {
239        let custom_resolver;
240        let resolver = if let Some(ns) = nameserver {
241            custom_resolver = self.create_custom_resolver(ns).await?;
242            &custom_resolver
243        } else {
244            &self.default_resolver
245        };
246        self.resolve_srv_core(resolver, service, protocol, domain)
247            .await
248    }
249
250    /// Core SRV resolution against an already-built resolver. Validates the
251    /// service/protocol labels (DNS query-injection guard) then queries
252    /// `_service._proto.domain`. Shared by the public [`resolve_srv`] entry
253    /// point and the `dig`-style SRV path in [`resolve`]. Label-validation
254    /// failures are [`SeerError::InvalidInput`] — they are caller mistakes, not
255    /// transient DNS failures, so they must not be advertised as retryable.
256    async fn resolve_srv_core(
257        &self,
258        resolver: &TokioResolver,
259        service: &str,
260        protocol: &str,
261        domain: &str,
262    ) -> Result<Vec<DnsRecord>> {
263        if !is_valid_srv_label(service) {
264            return Err(SeerError::InvalidInput(format!(
265                "invalid SRV service name: {}",
266                service
267            )));
268        }
269        if !is_valid_srv_label(protocol) {
270            return Err(SeerError::InvalidInput(format!(
271                "invalid SRV protocol name: {}",
272                protocol
273            )));
274        }
275
276        let query_name = format!("_{}._{}.{}", service, protocol, domain);
277
278        let Some(response) = dns_lookup_or_empty(
279            resolver.lookup(&query_name, HickoryRecordType::SRV).await,
280            "SRV",
281        )?
282        else {
283            return Ok(vec![]);
284        };
285
286        let records = response
287            .answers()
288            .iter()
289            .filter_map(|record| {
290                if let HickoryRData::SRV(srv) = &record.data {
291                    Some(DnsRecord {
292                        name: query_name.clone(),
293                        record_type: RecordType::SRV,
294                        ttl: record.ttl,
295                        data: RecordData::SRV {
296                            priority: srv.priority,
297                            weight: srv.weight,
298                            port: srv.port,
299                            target: srv.target.to_string(),
300                        },
301                    })
302                } else {
303                    None
304                }
305            })
306            .collect();
307
308        Ok(records)
309    }
310
311    async fn resolve_a(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
312        let Some(response) =
313            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::A).await, "A")?
314        else {
315            return Ok(vec![]);
316        };
317
318        let records = response
319            .answers()
320            .iter()
321            .filter_map(|record| {
322                if let HickoryRData::A(addr) = &record.data {
323                    Some(DnsRecord {
324                        name: domain.to_string(),
325                        record_type: RecordType::A,
326                        ttl: record.ttl,
327                        data: RecordData::A {
328                            address: addr.0.to_string(),
329                        },
330                    })
331                } else {
332                    None
333                }
334            })
335            .collect();
336
337        Ok(records)
338    }
339
340    async fn resolve_aaaa(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
341        let Some(response) = dns_lookup_or_empty(
342            resolver.lookup(domain, HickoryRecordType::AAAA).await,
343            "AAAA",
344        )?
345        else {
346            return Ok(vec![]);
347        };
348
349        let records = response
350            .answers()
351            .iter()
352            .filter_map(|record| {
353                if let HickoryRData::AAAA(addr) = &record.data {
354                    Some(DnsRecord {
355                        name: domain.to_string(),
356                        record_type: RecordType::AAAA,
357                        ttl: record.ttl,
358                        data: RecordData::AAAA {
359                            address: addr.0.to_string(),
360                        },
361                    })
362                } else {
363                    None
364                }
365            })
366            .collect();
367
368        Ok(records)
369    }
370
371    async fn resolve_cname(
372        &self,
373        resolver: &TokioResolver,
374        domain: &str,
375    ) -> Result<Vec<DnsRecord>> {
376        let Some(response) = dns_lookup_or_empty(
377            resolver.lookup(domain, HickoryRecordType::CNAME).await,
378            "CNAME",
379        )?
380        else {
381            return Ok(vec![]);
382        };
383
384        let records = response
385            .answers()
386            .iter()
387            .filter_map(|record| {
388                if let HickoryRData::CNAME(cname) = &record.data {
389                    Some(DnsRecord {
390                        name: domain.to_string(),
391                        record_type: RecordType::CNAME,
392                        ttl: record.ttl,
393                        data: RecordData::CNAME {
394                            target: cname.0.to_string(),
395                        },
396                    })
397                } else {
398                    None
399                }
400            })
401            .collect();
402
403        Ok(records)
404    }
405
406    async fn resolve_mx(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
407        let Some(response) =
408            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::MX).await, "MX")?
409        else {
410            return Ok(vec![]);
411        };
412
413        let mut records: Vec<DnsRecord> = response
414            .answers()
415            .iter()
416            .filter_map(|record| {
417                if let HickoryRData::MX(mx) = &record.data {
418                    Some(DnsRecord {
419                        name: domain.to_string(),
420                        record_type: RecordType::MX,
421                        ttl: record.ttl,
422                        data: RecordData::MX {
423                            preference: mx.preference,
424                            exchange: mx.exchange.to_string(),
425                        },
426                    })
427                } else {
428                    None
429                }
430            })
431            .collect();
432
433        records.sort_by_key(|r| {
434            if let RecordData::MX { preference, .. } = &r.data {
435                *preference
436            } else {
437                0
438            }
439        });
440
441        Ok(records)
442    }
443
444    async fn resolve_ns(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
445        let Some(response) =
446            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::NS).await, "NS")?
447        else {
448            return Ok(vec![]);
449        };
450
451        let records = response
452            .answers()
453            .iter()
454            .filter_map(|record| {
455                if let HickoryRData::NS(ns) = &record.data {
456                    Some(DnsRecord {
457                        name: domain.to_string(),
458                        record_type: RecordType::NS,
459                        ttl: record.ttl,
460                        data: RecordData::NS {
461                            nameserver: ns.0.to_string(),
462                        },
463                    })
464                } else {
465                    None
466                }
467            })
468            .collect();
469
470        Ok(records)
471    }
472
473    async fn resolve_txt(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
474        let Some(response) =
475            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::TXT).await, "TXT")?
476        else {
477            return Ok(vec![]);
478        };
479
480        let records = response
481            .answers()
482            .iter()
483            .filter_map(|record| {
484                if let HickoryRData::TXT(txt) = &record.data {
485                    let text = txt
486                        .txt_data
487                        .iter()
488                        .map(|data| String::from_utf8_lossy(data).to_string())
489                        .collect::<Vec<_>>()
490                        .join("");
491
492                    Some(DnsRecord {
493                        name: domain.to_string(),
494                        record_type: RecordType::TXT,
495                        ttl: record.ttl,
496                        data: RecordData::TXT { text },
497                    })
498                } else {
499                    None
500                }
501            })
502            .collect();
503
504        Ok(records)
505    }
506
507    async fn resolve_soa(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
508        let Some(response) =
509            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::SOA).await, "SOA")?
510        else {
511            return Ok(vec![]);
512        };
513
514        let records = response
515            .answers()
516            .iter()
517            .filter_map(|record| {
518                if let HickoryRData::SOA(soa) = &record.data {
519                    Some(DnsRecord {
520                        name: domain.to_string(),
521                        record_type: RecordType::SOA,
522                        ttl: record.ttl,
523                        data: RecordData::SOA {
524                            mname: soa.mname.to_string(),
525                            rname: soa.rname.to_string(),
526                            serial: soa.serial,
527                            // hickory models refresh/retry/expire as i32, but
528                            // they are unsigned 32-bit wire intervals. A value
529                            // >= 2^31 arrives as a negative i32; `try_into()`
530                            // would fail and zero it out, hiding the real
531                            // (large) value. `as u32` reinterprets the bits to
532                            // the correct unsigned value instead.
533                            refresh: soa.refresh as u32,
534                            retry: soa.retry as u32,
535                            expire: soa.expire as u32,
536                            minimum: soa.minimum,
537                        },
538                    })
539                } else {
540                    None
541                }
542            })
543            .collect();
544
545        Ok(records)
546    }
547
548    async fn resolve_ptr(&self, resolver: &TokioResolver, query: &str) -> Result<Vec<DnsRecord>> {
549        // If it's an IP address, convert to reverse DNS format
550        let query = if let Ok(ip) = IpAddr::from_str(query) {
551            reverse_dns_name(&ip)
552        } else {
553            query.to_string()
554        };
555
556        let Some(response) =
557            dns_lookup_or_empty(resolver.lookup(&query, HickoryRecordType::PTR).await, "PTR")?
558        else {
559            return Ok(vec![]);
560        };
561
562        let records = response
563            .answers()
564            .iter()
565            .filter_map(|record| {
566                if let HickoryRData::PTR(ptr) = &record.data {
567                    Some(DnsRecord {
568                        name: query.clone(),
569                        record_type: RecordType::PTR,
570                        ttl: record.ttl,
571                        data: RecordData::PTR {
572                            target: ptr.0.to_string(),
573                        },
574                    })
575                } else {
576                    None
577                }
578            })
579            .collect();
580
581        Ok(records)
582    }
583
584    async fn resolve_caa(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
585        let Some(response) =
586            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::CAA).await, "CAA")?
587        else {
588            return Ok(vec![]);
589        };
590
591        let records = response
592            .answers()
593            .iter()
594            .filter_map(|record| {
595                if let HickoryRData::CAA(caa) = &record.data {
596                    let (flags, tag, value) = parse_caa(caa);
597                    Some(DnsRecord {
598                        name: domain.to_string(),
599                        record_type: RecordType::CAA,
600                        ttl: record.ttl,
601                        data: RecordData::CAA { flags, tag, value },
602                    })
603                } else {
604                    None
605                }
606            })
607            .collect();
608
609        Ok(records)
610    }
611
612    async fn resolve_dnskey(
613        &self,
614        resolver: &TokioResolver,
615        domain: &str,
616    ) -> Result<Vec<DnsRecord>> {
617        use hickory_resolver::proto::dnssec::rdata::DNSSECRData;
618
619        let Some(response) = dns_lookup_or_empty(
620            resolver.lookup(domain, HickoryRecordType::DNSKEY).await,
621            "DNSKEY",
622        )?
623        else {
624            return Ok(vec![]);
625        };
626
627        let records = response
628            .answers()
629            .iter()
630            .filter_map(|record| {
631                if let HickoryRData::DNSSEC(DNSSECRData::DNSKEY(dnskey)) = &record.data {
632                    use base64::{engine::general_purpose::STANDARD, Engine};
633                    let public_key_buf = dnskey.public_key();
634                    let public_key = STANDARD.encode(public_key_buf.public_bytes());
635                    Some(DnsRecord {
636                        name: domain.to_string(),
637                        record_type: RecordType::DNSKEY,
638                        ttl: record.ttl,
639                        data: RecordData::DNSKEY {
640                            flags: dnskey.flags(),
641                            // Protocol is always 3 for DNSSEC (RFC 4034)
642                            protocol: 3,
643                            algorithm: u8::from(public_key_buf.algorithm()),
644                            public_key,
645                        },
646                    })
647                } else {
648                    None
649                }
650            })
651            .collect();
652
653        Ok(records)
654    }
655
656    async fn resolve_ds(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
657        use hickory_resolver::proto::dnssec::rdata::DNSSECRData;
658
659        let Some(response) =
660            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::DS).await, "DS")?
661        else {
662            return Ok(vec![]);
663        };
664
665        let records = response
666            .answers()
667            .iter()
668            .filter_map(|record| {
669                if let HickoryRData::DNSSEC(DNSSECRData::DS(ds)) = &record.data {
670                    let digest = ds
671                        .digest()
672                        .iter()
673                        .map(|b| format!("{:02X}", b))
674                        .collect::<String>();
675                    Some(DnsRecord {
676                        name: domain.to_string(),
677                        record_type: RecordType::DS,
678                        ttl: record.ttl,
679                        data: RecordData::DS {
680                            key_tag: ds.key_tag(),
681                            algorithm: u8::from(ds.algorithm()),
682                            digest_type: u8::from(ds.digest_type()),
683                            digest,
684                        },
685                    })
686                } else {
687                    None
688                }
689            })
690            .collect();
691
692        Ok(records)
693    }
694
695    async fn resolve_tlsa(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
696        // TLSA queries are how DANE clients discover the certificate
697        // association data for a TLS endpoint. The convention is
698        // `_<port>._<proto>.<host>` (e.g. `_443._tcp.example.com`); seer
699        // does not enforce the label shape because TLSA is also used for
700        // other transports.
701        let Some(response) = dns_lookup_or_empty(
702            resolver.lookup(domain, HickoryRecordType::TLSA).await,
703            "TLSA",
704        )?
705        else {
706            return Ok(vec![]);
707        };
708
709        let records = response
710            .answers()
711            .iter()
712            .filter_map(|record| {
713                if let HickoryRData::TLSA(tlsa) = &record.data {
714                    let cert_data = tlsa
715                        .cert_data
716                        .iter()
717                        .map(|b| format!("{:02X}", b))
718                        .collect::<String>();
719                    Some(DnsRecord {
720                        name: domain.to_string(),
721                        record_type: RecordType::TLSA,
722                        ttl: record.ttl,
723                        data: RecordData::TLSA {
724                            cert_usage: u8::from(tlsa.cert_usage),
725                            selector: u8::from(tlsa.selector),
726                            matching: u8::from(tlsa.matching),
727                            cert_data,
728                        },
729                    })
730                } else {
731                    None
732                }
733            })
734            .collect();
735
736        Ok(records)
737    }
738
739    async fn resolve_sshfp(
740        &self,
741        resolver: &TokioResolver,
742        domain: &str,
743    ) -> Result<Vec<DnsRecord>> {
744        let Some(response) = dns_lookup_or_empty(
745            resolver.lookup(domain, HickoryRecordType::SSHFP).await,
746            "SSHFP",
747        )?
748        else {
749            return Ok(vec![]);
750        };
751
752        let records = response
753            .answers()
754            .iter()
755            .filter_map(|record| {
756                if let HickoryRData::SSHFP(sshfp) = &record.data {
757                    let fingerprint = sshfp
758                        .fingerprint
759                        .iter()
760                        .map(|b| format!("{:02X}", b))
761                        .collect::<String>();
762                    Some(DnsRecord {
763                        name: domain.to_string(),
764                        record_type: RecordType::SSHFP,
765                        ttl: record.ttl,
766                        data: RecordData::SSHFP {
767                            algorithm: u8::from(sshfp.algorithm),
768                            fingerprint_type: u8::from(sshfp.fingerprint_type),
769                            fingerprint,
770                        },
771                    })
772                } else {
773                    None
774                }
775            })
776            .collect();
777
778        Ok(records)
779    }
780
781    async fn resolve_naptr(
782        &self,
783        resolver: &TokioResolver,
784        domain: &str,
785    ) -> Result<Vec<DnsRecord>> {
786        let Some(response) = dns_lookup_or_empty(
787            resolver.lookup(domain, HickoryRecordType::NAPTR).await,
788            "NAPTR",
789        )?
790        else {
791            return Ok(vec![]);
792        };
793
794        let records = response
795            .answers()
796            .iter()
797            .filter_map(|record| {
798                if let HickoryRData::NAPTR(naptr) = &record.data {
799                    Some(DnsRecord {
800                        name: domain.to_string(),
801                        record_type: RecordType::NAPTR,
802                        ttl: record.ttl,
803                        // flags/services/regexp are DNS <character-string>s
804                        // (raw bytes); they are conventionally ASCII, so a
805                        // lossy decode is a faithful, panic-free rendering.
806                        data: RecordData::NAPTR {
807                            order: naptr.order,
808                            preference: naptr.preference,
809                            flags: String::from_utf8_lossy(&naptr.flags).into_owned(),
810                            services: String::from_utf8_lossy(&naptr.services).into_owned(),
811                            regexp: String::from_utf8_lossy(&naptr.regexp).into_owned(),
812                            replacement: naptr.replacement.to_string(),
813                        },
814                    })
815                } else {
816                    None
817                }
818            })
819            .collect();
820
821        Ok(records)
822    }
823
824    async fn resolve_any(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
825        // Query common record types concurrently — previously these ran
826        // serially, making `ANY` ~7x slower than a single query (#61).
827        // `join_all` preserves input order, so the merged record list keeps the
828        // A, AAAA, MX, … ordering.
829        let record_types = [
830            RecordType::A,
831            RecordType::AAAA,
832            RecordType::MX,
833            RecordType::NS,
834            RecordType::TXT,
835            RecordType::SOA,
836            RecordType::CAA,
837        ];
838
839        let results = futures::future::join_all(
840            record_types
841                .into_iter()
842                .map(|record_type| self.resolve_type(resolver, domain, record_type)),
843        )
844        .await;
845
846        // Track whether any sub-query actually succeeded (an empty answer
847        // for an existing domain still counts as success). If every type
848        // errored — e.g. the resolver is unreachable — surface that error
849        // rather than returning an empty set that reads as "no records".
850        let mut all_records = Vec::new();
851        let mut any_ok = false;
852        let mut last_err = None;
853        for result in results {
854            match result {
855                Ok(records) => {
856                    any_ok = true;
857                    all_records.extend(records);
858                }
859                Err(e) => last_err = Some(e),
860            }
861        }
862
863        match last_err {
864            Some(e) if !any_ok => Err(e),
865            _ => Ok(all_records),
866        }
867    }
868
869    async fn resolve_type(
870        &self,
871        resolver: &TokioResolver,
872        domain: &str,
873        record_type: RecordType,
874    ) -> Result<Vec<DnsRecord>> {
875        match record_type {
876            RecordType::A => self.resolve_a(resolver, domain).await,
877            RecordType::AAAA => self.resolve_aaaa(resolver, domain).await,
878            RecordType::CNAME => self.resolve_cname(resolver, domain).await,
879            RecordType::MX => self.resolve_mx(resolver, domain).await,
880            RecordType::NS => self.resolve_ns(resolver, domain).await,
881            RecordType::TXT => self.resolve_txt(resolver, domain).await,
882            RecordType::SOA => self.resolve_soa(resolver, domain).await,
883            RecordType::CAA => self.resolve_caa(resolver, domain).await,
884            RecordType::DNSKEY => self.resolve_dnskey(resolver, domain).await,
885            RecordType::DS => self.resolve_ds(resolver, domain).await,
886            _ => Err(SeerError::DnsError("unsupported record type".to_string())),
887        }
888    }
889}
890
891/// Whether a domain appears to exist in the public DNS. Used as a
892/// corroborating availability signal when registry data (RDAP/WHOIS) is
893/// inconclusive — e.g. a thin/blocked WHOIS body and an RDAP failure that is
894/// not an authoritative 404.
895#[derive(Debug, Clone, Copy, PartialEq, Eq)]
896pub enum DnsPresence {
897    /// The apex returned NS records — the domain is delegated and exists.
898    Present,
899    /// NXDOMAIN / empty answer — the domain has no DNS presence.
900    Absent,
901    /// The DNS query itself failed; presence is unknown.
902    Unknown,
903}
904
905/// Maps an apex NS lookup result to a [`DnsPresence`]. Pure so the mapping is
906/// unit-testable without a live resolver. `resolve(.., NS, ..)` already folds
907/// NXDOMAIN/NODATA into `Ok(vec![])` (see `dns_lookup_or_empty`), so an empty
908/// `Ok` is the "no presence" signal and an `Err` is a genuine query failure.
909fn classify_ns_presence(result: &Result<Vec<DnsRecord>>) -> DnsPresence {
910    match result {
911        Ok(records) if records.is_empty() => DnsPresence::Absent,
912        Ok(_) => DnsPresence::Present,
913        Err(_) => DnsPresence::Unknown,
914    }
915}
916
917impl DnsResolver {
918    /// Probes whether a domain has any DNS presence by querying its apex NS
919    /// records. A registered, delegated domain returns NS records; an
920    /// unregistered domain returns NXDOMAIN (an empty record set).
921    ///
922    /// This is a heuristic, not proof: a registered-but-undelegated domain
923    /// also has no NS records, so callers should treat
924    /// [`DnsPresence::Absent`] as "likely available" (medium confidence).
925    pub async fn presence(&self, domain: &str) -> DnsPresence {
926        classify_ns_presence(&self.resolve(domain, RecordType::NS, None).await)
927    }
928}
929
930// Domain normalization is now handled by the shared validation module
931
932/// Prepares the query string for a DNS lookup.
933///
934/// PTR queries may be given a raw IP literal. IPv6 literals in particular must
935/// NOT pass through [`normalize_domain`]: its trailing-`:port` strip heuristic
936/// truncates the final hextet (e.g. `::1111` → dropped) and the remaining `:`
937/// separators then fail character validation, so IPv6 reverse lookups errored
938/// out with "Invalid domain name" before ever reaching `resolve_ptr`. For PTR
939/// queries we therefore detect an IP literal up front and pass it through in
940/// canonical form; everything else (domains, and PTR queries given a
941/// reverse-DNS name such as `1.1.1.1.in-addr.arpa`) is normalized as usual.
942fn prepare_query(domain: &str, record_type: RecordType) -> Result<String> {
943    if record_type == RecordType::PTR {
944        if let Ok(ip) = IpAddr::from_str(domain.trim()) {
945            return Ok(ip.to_string());
946        }
947    }
948    normalize_domain(domain)
949}
950
951/// Parses a `dig`-style SRV query name of the form `_service._proto.name` into
952/// its `(service, protocol, name)` parts, with the leading underscores
953/// stripped. Returns `None` when the input is not in that shape — e.g. a bare
954/// domain with no service/proto labels — so callers can surface a usage hint.
955fn parse_srv_query(name: &str) -> Option<(String, String, String)> {
956    let mut parts = name.splitn(3, '.');
957    let service = parts.next()?.strip_prefix('_')?;
958    let protocol = parts.next()?.strip_prefix('_')?;
959    let rest = parts.next()?;
960    if service.is_empty() || protocol.is_empty() || rest.is_empty() {
961        return None;
962    }
963    Some((service.to_string(), protocol.to_string(), rest.to_string()))
964}
965
966fn reverse_dns_name(ip: &IpAddr) -> String {
967    match ip {
968        IpAddr::V4(addr) => {
969            let octets = addr.octets();
970            format!(
971                "{}.{}.{}.{}.in-addr.arpa",
972                octets[3], octets[2], octets[1], octets[0]
973            )
974        }
975        IpAddr::V6(addr) => {
976            let segments = addr.segments();
977            // 32 hex nibbles + 31 dots + ".ip6.arpa" (9) = 72 chars
978            let mut result = String::with_capacity(72);
979            let mut first = true;
980            for segment in segments.iter().rev() {
981                for shift in [0, 4, 8, 12] {
982                    if !first {
983                        result.push('.');
984                    }
985                    first = false;
986                    let nibble = (segment >> shift) & 0xF;
987                    result
988                        .push(char::from_digit(nibble as u32, 16).expect("nibble is always 0-15"));
989                }
990            }
991            result.push_str(".ip6.arpa");
992            result
993        }
994    }
995}
996
997fn parse_caa(caa: &CAA) -> (u8, String, String) {
998    // hickory 0.26: CAA fields are public. `issuer_critical` and `tag` are
999    // plain fields; `value` is a `Vec<u8>` because RFC 8659 permits binary
1000    // values for unknown property types. For seer's reporting purposes the
1001    // common tags (issue/issuewild/iodef) are always UTF-8, so a lossy
1002    // conversion preserves prior behavior without panicking on the rare
1003    // binary case.
1004    let flags = if caa.issuer_critical { 128 } else { 0 };
1005    let tag = caa.tag.clone();
1006    let value = String::from_utf8_lossy(&caa.value).to_string();
1007    (flags, tag, value)
1008}
1009
1010/// Validates SRV service/protocol labels (alphanumeric and hyphens only, no dots)
1011fn is_valid_srv_label(label: &str) -> bool {
1012    !label.is_empty()
1013        && label.len() <= 63
1014        && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
1015        && !label.starts_with('-')
1016        && !label.ends_with('-')
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    //! Unit tests for the pure helpers and public surface of the DNS
1022    //! resolver. Tests that would exercise the hickory wire protocol
1023    //! are covered by live-network tests marked `#[ignore]` in the
1024    //! sibling modules (`dns/dnssec.rs`, `dns/follow.rs`). Deeper
1025    //! coverage of `resolve_*` paths would require a hickory mock,
1026    //! which is out of scope for this module.
1027    //
1028    // TODO: mock hickory resolver for full path coverage.
1029
1030    use super::*;
1031    use std::net::{Ipv4Addr, Ipv6Addr};
1032
1033    // --- RecordType::from_str edge cases -----------------------------
1034
1035    #[test]
1036    fn record_type_from_str_accepts_lowercase() {
1037        assert_eq!(RecordType::from_str("a").unwrap(), RecordType::A);
1038        assert_eq!(RecordType::from_str("mx").unwrap(), RecordType::MX);
1039        assert_eq!(RecordType::from_str("cname").unwrap(), RecordType::CNAME);
1040        assert_eq!(RecordType::from_str("dnskey").unwrap(), RecordType::DNSKEY);
1041    }
1042
1043    #[test]
1044    fn record_type_from_str_accepts_mixed_case() {
1045        assert_eq!(RecordType::from_str("Mx").unwrap(), RecordType::MX);
1046        assert_eq!(RecordType::from_str("cNaMe").unwrap(), RecordType::CNAME);
1047    }
1048
1049    #[test]
1050    fn record_type_from_str_rejects_whitespace_padded() {
1051        // No trim is done inside from_str; leading/trailing whitespace
1052        // must currently cause a parse error so callers don't pass
1053        // malformed labels through.
1054        assert!(RecordType::from_str(" A").is_err());
1055        assert!(RecordType::from_str("A ").is_err());
1056        assert!(RecordType::from_str("\tA\n").is_err());
1057    }
1058
1059    #[test]
1060    fn record_type_from_str_rejects_unknown() {
1061        assert!(RecordType::from_str("NOTAREAL").is_err());
1062        assert!(RecordType::from_str("A1").is_err());
1063        assert!(RecordType::from_str("").is_err());
1064    }
1065
1066    #[test]
1067    fn record_type_from_str_accepts_star_as_any() {
1068        assert_eq!(RecordType::from_str("*").unwrap(), RecordType::ANY);
1069        assert_eq!(RecordType::from_str("ANY").unwrap(), RecordType::ANY);
1070        assert_eq!(RecordType::from_str("any").unwrap(), RecordType::ANY);
1071    }
1072
1073    // --- is_valid_srv_label ------------------------------------------
1074
1075    #[test]
1076    fn srv_label_accepts_alphanumeric_and_hyphen() {
1077        assert!(is_valid_srv_label("http"));
1078        assert!(is_valid_srv_label("ldap-tls"));
1079        assert!(is_valid_srv_label("a1"));
1080        assert!(is_valid_srv_label("tcp"));
1081    }
1082
1083    #[test]
1084    fn srv_label_rejects_empty() {
1085        assert!(!is_valid_srv_label(""));
1086    }
1087
1088    #[test]
1089    fn srv_label_rejects_leading_or_trailing_hyphen() {
1090        assert!(!is_valid_srv_label("-http"));
1091        assert!(!is_valid_srv_label("http-"));
1092        assert!(!is_valid_srv_label("-"));
1093    }
1094
1095    #[test]
1096    fn srv_label_rejects_dots() {
1097        // Dots would let an attacker construct `_service._tcp.evil.com.target`
1098        // and pivot the query to a different domain.
1099        assert!(!is_valid_srv_label("http.evil"));
1100        assert!(!is_valid_srv_label("a.b"));
1101    }
1102
1103    #[test]
1104    fn srv_label_rejects_special_chars() {
1105        assert!(!is_valid_srv_label("http evil"));
1106        assert!(!is_valid_srv_label("http/evil"));
1107        assert!(!is_valid_srv_label("http\0"));
1108        assert!(!is_valid_srv_label("http\n"));
1109    }
1110
1111    #[test]
1112    fn srv_label_rejects_over_63_chars() {
1113        let too_long = "a".repeat(64);
1114        assert!(!is_valid_srv_label(&too_long));
1115        let exactly_63 = "a".repeat(63);
1116        assert!(is_valid_srv_label(&exactly_63));
1117    }
1118
1119    // --- classify_ns_presence ----------------------------------------
1120
1121    #[test]
1122    fn classify_ns_presence_absent_on_empty_ok() {
1123        // resolve(.., NS) folds NXDOMAIN/NODATA into Ok(vec![]).
1124        let r: Result<Vec<DnsRecord>> = Ok(vec![]);
1125        assert_eq!(classify_ns_presence(&r), DnsPresence::Absent);
1126    }
1127
1128    #[test]
1129    fn classify_ns_presence_present_on_records() {
1130        let rec = DnsRecord {
1131            name: "example.test.".to_string(),
1132            record_type: RecordType::NS,
1133            ttl: 3600,
1134            data: RecordData::NS {
1135                nameserver: "ns1.example.net.".to_string(),
1136            },
1137        };
1138        let r: Result<Vec<DnsRecord>> = Ok(vec![rec]);
1139        assert_eq!(classify_ns_presence(&r), DnsPresence::Present);
1140    }
1141
1142    #[test]
1143    fn classify_ns_presence_unknown_on_error() {
1144        let r: Result<Vec<DnsRecord>> = Err(SeerError::DnsError("servfail".to_string()));
1145        assert_eq!(classify_ns_presence(&r), DnsPresence::Unknown);
1146    }
1147
1148    // --- reverse_dns_name --------------------------------------------
1149
1150    #[test]
1151    fn reverse_dns_name_formats_ipv4_correctly() {
1152        let ip: IpAddr = Ipv4Addr::new(192, 0, 2, 1).into();
1153        assert_eq!(reverse_dns_name(&ip), "1.2.0.192.in-addr.arpa");
1154    }
1155
1156    #[test]
1157    fn reverse_dns_name_formats_ipv6_correctly() {
1158        // ::1 (loopback) → 32 nibbles of 0 followed by ...0.0.0.1 reversed.
1159        let ip: IpAddr = Ipv6Addr::LOCALHOST.into();
1160        let name = reverse_dns_name(&ip);
1161        assert!(
1162            name.ends_with(".ip6.arpa"),
1163            "must end with .ip6.arpa; got: {}",
1164            name
1165        );
1166        // The first nibble (most-reversed position) must be 1 (from ::1 low bit).
1167        assert!(
1168            name.starts_with("1."),
1169            "expected '1.' prefix, got: {}",
1170            name
1171        );
1172        // 32 nibbles + 31 dots + ".ip6.arpa" (9 chars) = 72.
1173        assert_eq!(name.len(), 72);
1174    }
1175
1176    // --- DnsResolver construction ------------------------------------
1177
1178    #[test]
1179    fn resolver_new_has_default_timeout() {
1180        let r = DnsResolver::new();
1181        assert_eq!(r.timeout, DEFAULT_TIMEOUT);
1182    }
1183
1184    #[test]
1185    fn resolver_with_timeout_overrides_default() {
1186        let custom = Duration::from_secs(42);
1187        let r = DnsResolver::new().with_timeout(custom);
1188        assert_eq!(r.timeout, custom);
1189    }
1190
1191    #[test]
1192    fn resolver_default_matches_new() {
1193        let a = DnsResolver::default();
1194        let b = DnsResolver::new();
1195        assert_eq!(a.timeout, b.timeout);
1196    }
1197
1198    // --- create_custom_resolver validation ---------------------------
1199
1200    #[tokio::test]
1201    async fn custom_resolver_rejects_invalid_input() {
1202        // After hostname support was added, a string that is neither a
1203        // valid IP nor a resolvable hostname should fail with a clear
1204        // "failed to resolve" error rather than panicking or hanging.
1205        // We pick a name that is *syntactically* impossible to resolve.
1206        let r = DnsResolver::new();
1207        let err = r.create_custom_resolver("..").await.unwrap_err();
1208        let msg = err.to_string().to_lowercase();
1209        assert!(
1210            msg.contains("dns resolution failed") || msg.contains("invalid"),
1211            "expected resolution failure, got: {}",
1212            msg
1213        );
1214    }
1215
1216    #[tokio::test]
1217    async fn custom_resolver_rejects_private_ipv4() {
1218        // SSRF defense: private / reserved ranges must be blocked even
1219        // when passed as a literal IP rather than a hostname.
1220        let r = DnsResolver::new();
1221        for reserved in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.169.254"] {
1222            let err = r.create_custom_resolver(reserved).await.unwrap_err();
1223            let msg = err.to_string().to_lowercase();
1224            assert!(
1225                msg.contains("blocked") || msg.contains("reserved"),
1226                "reserved IP {} must be rejected, got error: {}",
1227                reserved,
1228                msg
1229            );
1230        }
1231    }
1232
1233    #[tokio::test]
1234    async fn custom_resolver_rejects_loopback_ipv6() {
1235        let r = DnsResolver::new();
1236        let err = r.create_custom_resolver("::1").await.unwrap_err();
1237        let msg = err.to_string().to_lowercase();
1238        assert!(
1239            msg.contains("blocked") || msg.contains("reserved"),
1240            "::1 must be rejected, got error: {}",
1241            msg
1242        );
1243    }
1244
1245    #[tokio::test]
1246    async fn custom_resolver_accepts_public_ipv4() {
1247        // A known public resolver IP must be acceptable.
1248        let r = DnsResolver::new();
1249        let result = r.create_custom_resolver("8.8.8.8").await;
1250        assert!(
1251            result.is_ok(),
1252            "8.8.8.8 must be accepted as a public nameserver, got: {:?}",
1253            result.err()
1254        );
1255    }
1256
1257    // --- SRV query validation (integration between helper + resolver) ----
1258
1259    #[tokio::test]
1260    async fn resolve_srv_rejects_invalid_service_label() {
1261        let r = DnsResolver::new();
1262        // With_dot service name would construct a malformed DNS query.
1263        let result = r.resolve_srv("http.evil", "tcp", "example.com", None).await;
1264        assert!(result.is_err());
1265        let msg = result.unwrap_err().to_string().to_lowercase();
1266        assert!(
1267            msg.contains("invalid srv service"),
1268            "expected SRV service validation error, got: {}",
1269            msg
1270        );
1271    }
1272
1273    #[tokio::test]
1274    async fn resolve_srv_rejects_invalid_protocol_label() {
1275        let r = DnsResolver::new();
1276        let result = r.resolve_srv("http", "tcp.evil", "example.com", None).await;
1277        assert!(result.is_err());
1278        let msg = result.unwrap_err().to_string().to_lowercase();
1279        assert!(
1280            msg.contains("invalid srv protocol"),
1281            "expected SRV protocol validation error, got: {}",
1282            msg
1283        );
1284    }
1285
1286    // --- Normalization applied before resolution ---------------------
1287
1288    #[tokio::test]
1289    async fn resolve_normalizes_uppercase_domain_input() {
1290        // We can't hit the network in unit tests, but we can at least
1291        // assert that normalization rejects clearly-invalid input
1292        // before any network call is made. Domains with a leading `.`
1293        // are rejected by the normalizer.
1294        let r = DnsResolver::new();
1295        let result = r.resolve(".bad.example", RecordType::A, None).await;
1296        assert!(result.is_err(), "leading-dot domain must be rejected");
1297    }
1298
1299    // --- SRV record -------------------------------------------------
1300
1301    // --- SRV via dig-style names (parse_srv_query) -------------------
1302
1303    #[test]
1304    fn parse_srv_query_extracts_service_proto_and_name() {
1305        assert_eq!(
1306            parse_srv_query("_sip._tcp.example.com"),
1307            Some((
1308                "sip".to_string(),
1309                "tcp".to_string(),
1310                "example.com".to_string()
1311            ))
1312        );
1313    }
1314
1315    #[test]
1316    fn parse_srv_query_keeps_multilabel_domain() {
1317        assert_eq!(
1318            parse_srv_query("_sip._tcp.sip.voice.google.com"),
1319            Some((
1320                "sip".to_string(),
1321                "tcp".to_string(),
1322                "sip.voice.google.com".to_string()
1323            ))
1324        );
1325    }
1326
1327    #[test]
1328    fn parse_srv_query_rejects_bare_domain() {
1329        assert_eq!(parse_srv_query("example.com"), None);
1330    }
1331
1332    #[test]
1333    fn parse_srv_query_rejects_missing_proto_label() {
1334        // Second label must be an `_proto` label.
1335        assert_eq!(parse_srv_query("_sip.example.com"), None);
1336    }
1337
1338    #[tokio::test]
1339    async fn resolve_rejects_bare_domain_for_srv_as_input_error() {
1340        // A bare domain (no _service._proto labels) cannot be an SRV query.
1341        // This is a usage/input error — NOT a transient DNS failure — so it
1342        // must surface as InvalidInput (which maps to a permanent, non-retryable
1343        // signal across the Python/MCP boundary), and still carry the hint.
1344        let r = DnsResolver::new();
1345        let err = r
1346            .resolve("example.com", RecordType::SRV, None)
1347            .await
1348            .expect_err("bare-domain SRV must error");
1349        assert!(
1350            matches!(err, SeerError::InvalidInput(_)),
1351            "bare-domain SRV should be an input error, got: {err:?}"
1352        );
1353        assert!(err.to_string().contains("_service._proto"));
1354    }
1355
1356    #[tokio::test]
1357    #[ignore = "live network"]
1358    async fn resolve_srv_via_dig_style_name_returns_records() {
1359        // _caldavs._tcp.google.com is a long-standing public SRV record
1360        // (CalDAV discovery → calendar.google.com:443).
1361        let r = DnsResolver::new();
1362        let records = r
1363            .resolve("_caldavs._tcp.google.com", RecordType::SRV, None)
1364            .await
1365            .expect("dig-style SRV lookup should succeed");
1366        assert!(!records.is_empty(), "expected SRV records");
1367        assert!(records.iter().all(|r| r.record_type == RecordType::SRV));
1368    }
1369
1370    #[tokio::test]
1371    #[ignore = "live network"]
1372    async fn resolve_naptr_returns_records() {
1373        // sip2sip.info publishes stable NAPTR records for SIP discovery.
1374        let r = DnsResolver::new();
1375        let records = r
1376            .resolve("sip2sip.info", RecordType::NAPTR, None)
1377            .await
1378            .expect("NAPTR lookup should succeed");
1379        assert!(!records.is_empty(), "expected NAPTR records");
1380        assert!(records.iter().all(|r| r.record_type == RecordType::NAPTR));
1381    }
1382
1383    // --- prepare_query: PTR must accept raw IP literals (incl. IPv6) --
1384
1385    #[test]
1386    fn prepare_query_passes_ipv6_literal_through_for_ptr() {
1387        // Regression: normalize_domain's port-strip heuristic mangled IPv6
1388        // literals (the trailing `:1111` group looks like a `:port`), so IPv6
1389        // reverse lookups failed with "Invalid domain name" before ever
1390        // reaching resolve_ptr. PTR queries for IP literals must bypass domain
1391        // normalization.
1392        let out = prepare_query("2606:4700:4700::1111", RecordType::PTR).unwrap();
1393        assert_eq!(out, "2606:4700:4700::1111");
1394    }
1395
1396    #[test]
1397    fn prepare_query_passes_ipv6_loopback_through_for_ptr() {
1398        let out = prepare_query("::1", RecordType::PTR).unwrap();
1399        assert_eq!(out, "::1");
1400    }
1401
1402    #[test]
1403    fn prepare_query_passes_ipv4_literal_through_for_ptr() {
1404        let out = prepare_query("8.8.8.8", RecordType::PTR).unwrap();
1405        assert_eq!(out, "8.8.8.8");
1406    }
1407
1408    #[test]
1409    fn prepare_query_normalizes_non_ip_ptr_names() {
1410        // A reverse-DNS name (not an IP literal) still gets normalized.
1411        let out = prepare_query("1.1.1.1.in-addr.arpa", RecordType::PTR).unwrap();
1412        assert_eq!(out, "1.1.1.1.in-addr.arpa");
1413    }
1414
1415    #[test]
1416    fn prepare_query_normalizes_domains_for_non_ptr() {
1417        let out = prepare_query("HTTPS://WWW.Example.com/path", RecordType::A).unwrap();
1418        assert_eq!(out, "example.com");
1419    }
1420}