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