1use std::net::IpAddr;
12use std::str::FromStr;
13use std::sync::Arc;
14use std::time::Duration;
15
16use hickory_resolver::config::{NameServerConfig, ResolveHosts, ResolverConfig, GOOGLE};
17use hickory_resolver::net::runtime::TokioRuntimeProvider;
18use hickory_resolver::net::NetError;
19use hickory_resolver::proto::dnssec::PublicKey;
20use hickory_resolver::proto::rr::rdata::CAA;
21use hickory_resolver::proto::rr::{RData as HickoryRData, RecordType as HickoryRecordType};
22use hickory_resolver::TokioResolver;
23use tracing::{debug, instrument};
24
25use super::nameserver::{NameserverProtocol, NameserverSpec};
26use super::records::{DnsRecord, RecordData, RecordType};
27use crate::error::{Result, SeerError};
28use crate::validation::normalize_domain;
29
30fn dns_lookup_or_empty<T>(
34 result: std::result::Result<T, NetError>,
35 record_type: &str,
36) -> Result<Option<T>> {
37 match result {
38 Ok(response) => Ok(Some(response)),
39 Err(e) if e.is_no_records_found() => Ok(None),
40 Err(e) => Err(SeerError::DnsError(format!(
41 "{} lookup failed: {}",
42 record_type, e
43 ))),
44 }
45}
46
47const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
50
51fn build_resolver(config: ResolverConfig, timeout: Duration) -> Result<TokioResolver> {
60 let mut builder = TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
61 {
62 let opts = builder.options_mut();
63 opts.timeout = timeout;
64 opts.attempts = 2;
65 opts.use_hosts_file = ResolveHosts::Never;
66 }
67 builder
68 .build()
69 .map_err(|e| SeerError::DnsError(format!("failed to construct DNS resolver: {}", e)))
70}
71
72fn build_default_resolver(timeout: Duration) -> TokioResolver {
79 build_resolver(ResolverConfig::udp_and_tcp(&GOOGLE), timeout)
80 .expect("default resolver build cannot fail with the bundled webpki root store")
81}
82
83fn build_upstream_config(
93 spec: &NameserverSpec,
94 ips: &[IpAddr],
95 port_override: Option<u16>,
96) -> ResolverConfig {
97 let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
98 let port = port_override.unwrap_or(spec.port);
99 for ip in ips {
100 let mut ns = match spec.protocol {
101 NameserverProtocol::Udp => NameServerConfig::udp(*ip),
102 NameserverProtocol::Tls => NameServerConfig::tls(*ip, Arc::from(spec.tls_name())),
103 NameserverProtocol::Https => NameServerConfig::https(
104 *ip,
105 Arc::from(spec.tls_name()),
106 spec.path.as_deref().map(Arc::from),
107 ),
108 };
109 for connection in &mut ns.connections {
110 connection.port = port;
111 }
112 config.add_name_server(ns);
113 }
114 config
115}
116
117#[derive(Clone)]
125pub struct DnsResolver {
126 timeout: Duration,
127 default_resolver: TokioResolver,
130 port_override: Option<u16>,
135 allow_private_hosts: bool,
140}
141
142impl std::fmt::Debug for DnsResolver {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.debug_struct("DnsResolver")
145 .field("timeout", &self.timeout)
146 .finish()
147 }
148}
149
150impl Default for DnsResolver {
151 fn default() -> Self {
152 Self::new()
153 }
154}
155
156impl DnsResolver {
157 pub fn new() -> Self {
159 Self {
160 timeout: DEFAULT_TIMEOUT,
161 default_resolver: build_default_resolver(DEFAULT_TIMEOUT),
162 port_override: None,
163 allow_private_hosts: false,
164 }
165 }
166
167 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
179 Self::new().with_timeout(config.dns_timeout())
180 }
181
182 #[cfg(test)]
184 pub(crate) fn allowing_private_hosts(mut self) -> Self {
185 self.allow_private_hosts = true;
186 self
187 }
188
189 #[cfg(test)]
192 pub(crate) fn with_port(mut self, port: u16) -> Self {
193 self.port_override = Some(port);
194 self
195 }
196
197 pub fn with_timeout(mut self, timeout: Duration) -> Self {
201 self.timeout = timeout;
202 self.default_resolver = build_default_resolver(timeout);
203 self
204 }
205
206 async fn create_custom_resolver(&self, nameserver: &str) -> Result<TokioResolver> {
207 let spec = NameserverSpec::parse(nameserver)?;
212
213 let ips: Vec<IpAddr> = if let Ok(ip) = spec.host.parse::<IpAddr>() {
222 vec![ip]
223 } else {
224 let response = self
225 .default_resolver
226 .lookup_ip(spec.host.as_str())
227 .await
228 .map_err(|e| {
229 SeerError::DnsError(format!(
230 "failed to resolve nameserver hostname {}: {}",
231 spec.host, e
232 ))
233 })?;
234 let resolved: Vec<IpAddr> = response.iter().collect();
235 if resolved.is_empty() {
236 return Err(SeerError::DnsError(format!(
237 "nameserver {} did not resolve to any addresses",
238 spec.host
239 )));
240 }
241 resolved
242 };
243
244 if !self.allow_private_hosts {
251 for ip in &ips {
252 if let Some(reason) = crate::validation::describe_reserved_ip(ip) {
253 return Err(SeerError::DnsError(format!(
254 "nameserver {} blocked: {}",
255 nameserver, reason
256 )));
257 }
258 }
259 }
260
261 build_resolver(
262 build_upstream_config(&spec, &ips, self.port_override),
263 self.timeout,
264 )
265 }
266
267 #[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
277 pub async fn resolve(
278 &self,
279 domain: &str,
280 record_type: RecordType,
281 nameserver: Option<&str>,
282 ) -> Result<Vec<DnsRecord>> {
283 let custom_resolver;
285 let resolver = if let Some(ns) = nameserver {
286 custom_resolver = self.create_custom_resolver(ns).await?;
287 &custom_resolver
288 } else {
289 &self.default_resolver
290 };
291 let domain = prepare_query(domain, record_type)?;
292
293 debug!(nameserver = nameserver.unwrap_or("system"), "Resolving DNS");
294
295 match record_type {
296 RecordType::SRV => match parse_srv_query(&domain) {
297 Some((service, protocol, name)) => {
299 self.resolve_srv_core(resolver, &service, &protocol, &name)
300 .await
301 }
302 None => Err(srv_format_error()),
305 },
306 RecordType::ANY => self.resolve_any(resolver, &domain).await,
307 single => self.resolve_type(resolver, &domain, single).await,
308 }
309 }
310
311 #[instrument(skip(self), fields(domain = %domain, service = %service, protocol = %protocol))]
319 pub async fn resolve_srv(
320 &self,
321 service: &str,
322 protocol: &str,
323 domain: &str,
324 nameserver: Option<&str>,
325 ) -> Result<Vec<DnsRecord>> {
326 let domain = normalize_domain(domain)?;
329 let custom_resolver;
330 let resolver = if let Some(ns) = nameserver {
331 custom_resolver = self.create_custom_resolver(ns).await?;
332 &custom_resolver
333 } else {
334 &self.default_resolver
335 };
336 self.resolve_srv_core(resolver, service, protocol, &domain)
337 .await
338 }
339
340 async fn resolve_srv_core(
347 &self,
348 resolver: &TokioResolver,
349 service: &str,
350 protocol: &str,
351 domain: &str,
352 ) -> Result<Vec<DnsRecord>> {
353 if !is_valid_srv_label(service) {
354 return Err(SeerError::InvalidInput(format!(
355 "invalid SRV service name: {}",
356 service
357 )));
358 }
359 if !is_valid_srv_label(protocol) {
360 return Err(SeerError::InvalidInput(format!(
361 "invalid SRV protocol name: {}",
362 protocol
363 )));
364 }
365
366 let query_name = format!("_{}._{}.{}", service, protocol, domain);
367
368 let Some(response) = dns_lookup_or_empty(
369 resolver.lookup(&query_name, HickoryRecordType::SRV).await,
370 "SRV",
371 )?
372 else {
373 return Ok(vec![]);
374 };
375
376 let records = response
377 .answers()
378 .iter()
379 .filter_map(|record| {
380 if let HickoryRData::SRV(srv) = &record.data {
381 Some(DnsRecord {
382 name: query_name.clone(),
383 record_type: RecordType::SRV,
384 ttl: record.ttl,
385 data: RecordData::SRV {
386 priority: srv.priority,
387 weight: srv.weight,
388 port: srv.port,
389 target: srv.target.to_string(),
390 },
391 })
392 } else {
393 None
394 }
395 })
396 .collect();
397
398 Ok(records)
399 }
400
401 async fn resolve_type(
409 &self,
410 resolver: &TokioResolver,
411 domain: &str,
412 record_type: RecordType,
413 ) -> Result<Vec<DnsRecord>> {
414 match record_type {
415 RecordType::PTR => {
418 let query = if let Ok(ip) = IpAddr::from_str(domain) {
419 reverse_dns_name(&ip)
420 } else {
421 domain.to_string()
422 };
423 self.resolve_records(resolver, &query, RecordType::PTR)
424 .await
425 }
426 single => self.resolve_records(resolver, domain, single).await,
427 }
428 }
429
430 async fn resolve_records(
438 &self,
439 resolver: &TokioResolver,
440 domain: &str,
441 record_type: RecordType,
442 ) -> Result<Vec<DnsRecord>> {
443 let Some(wire_type) = wire_type(record_type) else {
444 return Err(unsupported_record_type(record_type));
445 };
446
447 let Some(response) = dns_lookup_or_empty(
448 resolver.lookup(domain, wire_type).await,
449 &record_type.to_string(),
450 )?
451 else {
452 return Ok(vec![]);
453 };
454
455 let mut records: Vec<DnsRecord> = response
456 .answers()
457 .iter()
458 .filter_map(|record| {
459 convert_rdata(record_type, &record.data).map(|data| DnsRecord {
460 name: domain.to_string(),
461 record_type,
462 ttl: record.ttl,
463 data,
464 })
465 })
466 .collect();
467
468 if record_type == RecordType::MX {
469 records.sort_by_key(|r| match &r.data {
470 RecordData::MX { preference, .. } => *preference,
471 _ => 0,
472 });
473 }
474
475 Ok(records)
476 }
477
478 async fn resolve_any(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
479 let record_types = [
484 RecordType::A,
485 RecordType::AAAA,
486 RecordType::MX,
487 RecordType::NS,
488 RecordType::TXT,
489 RecordType::SOA,
490 RecordType::CAA,
491 ];
492
493 let results = futures::future::join_all(
494 record_types
495 .into_iter()
496 .map(|record_type| self.resolve_type(resolver, domain, record_type)),
497 )
498 .await;
499
500 let mut all_records = Vec::new();
505 let mut any_ok = false;
506 let mut last_err = None;
507 for result in results {
508 match result {
509 Ok(records) => {
510 any_ok = true;
511 all_records.extend(records);
512 }
513 Err(e) => last_err = Some(e),
514 }
515 }
516
517 match last_err {
518 Some(e) if !any_ok => Err(e),
519 _ => Ok(all_records),
520 }
521 }
522}
523
524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
529pub enum DnsPresence {
530 Present,
532 Absent,
534 Unknown,
536}
537
538fn classify_ns_presence(result: &Result<Vec<DnsRecord>>) -> DnsPresence {
543 match result {
544 Ok(records) if records.is_empty() => DnsPresence::Absent,
545 Ok(_) => DnsPresence::Present,
546 Err(_) => DnsPresence::Unknown,
547 }
548}
549
550impl DnsResolver {
551 pub async fn presence(&self, domain: &str) -> DnsPresence {
559 classify_ns_presence(&self.resolve(domain, RecordType::NS, None).await)
560 }
561}
562
563fn prepare_query(domain: &str, record_type: RecordType) -> Result<String> {
576 if record_type == RecordType::PTR {
577 if let Ok(ip) = IpAddr::from_str(domain.trim()) {
578 return Ok(ip.to_string());
579 }
580 }
581 normalize_domain(domain)
582}
583
584pub(crate) fn parse_srv_query(name: &str) -> Option<(String, String, String)> {
589 let mut parts = name.splitn(3, '.');
590 let service = parts.next()?.strip_prefix('_')?;
591 let protocol = parts.next()?.strip_prefix('_')?;
592 let rest = parts.next()?;
593 if service.is_empty() || protocol.is_empty() || rest.is_empty() {
594 return None;
595 }
596 Some((service.to_string(), protocol.to_string(), rest.to_string()))
597}
598
599pub(crate) fn srv_format_error() -> SeerError {
603 SeerError::InvalidInput(
604 "SRV records require service name format: _service._proto.name".to_string(),
605 )
606}
607
608fn reverse_dns_name(ip: &IpAddr) -> String {
609 match ip {
610 IpAddr::V4(addr) => {
611 let octets = addr.octets();
612 format!(
613 "{}.{}.{}.{}.in-addr.arpa",
614 octets[3], octets[2], octets[1], octets[0]
615 )
616 }
617 IpAddr::V6(addr) => {
618 let segments = addr.segments();
619 let mut result = String::with_capacity(72);
621 let mut first = true;
622 for segment in segments.iter().rev() {
623 for shift in [0, 4, 8, 12] {
624 if !first {
625 result.push('.');
626 }
627 first = false;
628 let nibble = (segment >> shift) & 0xF;
629 result
630 .push(char::from_digit(nibble as u32, 16).expect("nibble is always 0-15"));
631 }
632 }
633 result.push_str(".ip6.arpa");
634 result
635 }
636 }
637}
638
639fn parse_caa(caa: &CAA) -> (u8, String, String) {
640 let flags = if caa.issuer_critical { 128 } else { 0 };
647 let tag = caa.tag.clone();
648 let value = String::from_utf8_lossy(&caa.value).to_string();
649 (flags, tag, value)
650}
651
652fn wire_type(record_type: RecordType) -> Option<HickoryRecordType> {
658 Some(match record_type {
659 RecordType::A => HickoryRecordType::A,
660 RecordType::AAAA => HickoryRecordType::AAAA,
661 RecordType::CNAME => HickoryRecordType::CNAME,
662 RecordType::MX => HickoryRecordType::MX,
663 RecordType::NS => HickoryRecordType::NS,
664 RecordType::TXT => HickoryRecordType::TXT,
665 RecordType::SOA => HickoryRecordType::SOA,
666 RecordType::PTR => HickoryRecordType::PTR,
667 RecordType::CAA => HickoryRecordType::CAA,
668 RecordType::DNSKEY => HickoryRecordType::DNSKEY,
669 RecordType::DS => HickoryRecordType::DS,
670 RecordType::TLSA => HickoryRecordType::TLSA,
676 RecordType::SSHFP => HickoryRecordType::SSHFP,
677 RecordType::NAPTR => HickoryRecordType::NAPTR,
678 RecordType::SRV | RecordType::ANY => return None,
679 })
680}
681
682fn unsupported_record_type(record_type: RecordType) -> SeerError {
685 SeerError::DnsError(format!("unsupported record type: {}", record_type))
686}
687
688fn hex_upper(bytes: &[u8]) -> String {
691 bytes.iter().map(|b| format!("{:02X}", b)).collect()
692}
693
694fn convert_rdata(record_type: RecordType, data: &HickoryRData) -> Option<RecordData> {
701 use hickory_resolver::proto::dnssec::rdata::DNSSECRData;
702
703 match (record_type, data) {
704 (RecordType::A, HickoryRData::A(addr)) => Some(RecordData::A {
705 address: addr.0.to_string(),
706 }),
707 (RecordType::AAAA, HickoryRData::AAAA(addr)) => Some(RecordData::AAAA {
708 address: addr.0.to_string(),
709 }),
710 (RecordType::CNAME, HickoryRData::CNAME(cname)) => Some(RecordData::CNAME {
711 target: cname.0.to_string(),
712 }),
713 (RecordType::MX, HickoryRData::MX(mx)) => Some(RecordData::MX {
714 preference: mx.preference,
715 exchange: mx.exchange.to_string(),
716 }),
717 (RecordType::NS, HickoryRData::NS(ns)) => Some(RecordData::NS {
718 nameserver: ns.0.to_string(),
719 }),
720 (RecordType::TXT, HickoryRData::TXT(txt)) => Some(RecordData::TXT {
721 text: txt
722 .txt_data
723 .iter()
724 .map(|data| String::from_utf8_lossy(data).to_string())
725 .collect::<Vec<_>>()
726 .join(""),
727 }),
728 (RecordType::SOA, HickoryRData::SOA(soa)) => Some(RecordData::SOA {
729 mname: soa.mname.to_string(),
730 rname: soa.rname.to_string(),
731 serial: soa.serial,
732 refresh: soa.refresh as u32,
738 retry: soa.retry as u32,
739 expire: soa.expire as u32,
740 minimum: soa.minimum,
741 }),
742 (RecordType::PTR, HickoryRData::PTR(ptr)) => Some(RecordData::PTR {
743 target: ptr.0.to_string(),
744 }),
745 (RecordType::CAA, HickoryRData::CAA(caa)) => {
746 let (flags, tag, value) = parse_caa(caa);
747 Some(RecordData::CAA { flags, tag, value })
748 }
749 (RecordType::DNSKEY, HickoryRData::DNSSEC(DNSSECRData::DNSKEY(dnskey))) => {
750 use base64::{engine::general_purpose::STANDARD, Engine};
751 let public_key_buf = dnskey.public_key();
752 Some(RecordData::DNSKEY {
753 flags: dnskey.flags(),
754 protocol: 3,
756 algorithm: u8::from(public_key_buf.algorithm()),
757 public_key: STANDARD.encode(public_key_buf.public_bytes()),
758 })
759 }
760 (RecordType::DS, HickoryRData::DNSSEC(DNSSECRData::DS(ds))) => Some(RecordData::DS {
761 key_tag: ds.key_tag(),
762 algorithm: u8::from(ds.algorithm()),
763 digest_type: u8::from(ds.digest_type()),
764 digest: hex_upper(ds.digest()),
765 }),
766 (RecordType::TLSA, HickoryRData::TLSA(tlsa)) => Some(RecordData::TLSA {
767 cert_usage: u8::from(tlsa.cert_usage),
768 selector: u8::from(tlsa.selector),
769 matching: u8::from(tlsa.matching),
770 cert_data: hex_upper(&tlsa.cert_data),
771 }),
772 (RecordType::SSHFP, HickoryRData::SSHFP(sshfp)) => Some(RecordData::SSHFP {
773 algorithm: u8::from(sshfp.algorithm),
774 fingerprint_type: u8::from(sshfp.fingerprint_type),
775 fingerprint: hex_upper(&sshfp.fingerprint),
776 }),
777 (RecordType::NAPTR, HickoryRData::NAPTR(naptr)) => Some(RecordData::NAPTR {
781 order: naptr.order,
782 preference: naptr.preference,
783 flags: String::from_utf8_lossy(&naptr.flags).into_owned(),
784 services: String::from_utf8_lossy(&naptr.services).into_owned(),
785 regexp: String::from_utf8_lossy(&naptr.regexp).into_owned(),
786 replacement: naptr.replacement.to_string(),
787 }),
788 _ => None,
789 }
790}
791
792fn is_valid_srv_label(label: &str) -> bool {
794 !label.is_empty()
795 && label.len() <= 63
796 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
797 && !label.starts_with('-')
798 && !label.ends_with('-')
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
811
812 #[test]
813 fn from_config_applies_dns_timeout() {
814 let mut config = crate::config::SeerConfig::default();
815 config.timeouts.dns_secs = 9;
816 let resolver = DnsResolver::from_config(&config);
817 assert_eq!(resolver.timeout, Duration::from_secs(9));
818 }
819 use std::net::{Ipv4Addr, Ipv6Addr};
820
821 #[test]
824 fn record_type_from_str_accepts_lowercase() {
825 assert_eq!(RecordType::from_str("a").unwrap(), RecordType::A);
826 assert_eq!(RecordType::from_str("mx").unwrap(), RecordType::MX);
827 assert_eq!(RecordType::from_str("cname").unwrap(), RecordType::CNAME);
828 assert_eq!(RecordType::from_str("dnskey").unwrap(), RecordType::DNSKEY);
829 }
830
831 #[test]
832 fn record_type_from_str_accepts_mixed_case() {
833 assert_eq!(RecordType::from_str("Mx").unwrap(), RecordType::MX);
834 assert_eq!(RecordType::from_str("cNaMe").unwrap(), RecordType::CNAME);
835 }
836
837 #[test]
838 fn record_type_from_str_rejects_whitespace_padded() {
839 assert!(RecordType::from_str(" A").is_err());
843 assert!(RecordType::from_str("A ").is_err());
844 assert!(RecordType::from_str("\tA\n").is_err());
845 }
846
847 #[test]
848 fn record_type_from_str_rejects_unknown() {
849 assert!(RecordType::from_str("NOTAREAL").is_err());
850 assert!(RecordType::from_str("A1").is_err());
851 assert!(RecordType::from_str("").is_err());
852 }
853
854 #[test]
855 fn record_type_from_str_accepts_star_as_any() {
856 assert_eq!(RecordType::from_str("*").unwrap(), RecordType::ANY);
857 assert_eq!(RecordType::from_str("ANY").unwrap(), RecordType::ANY);
858 assert_eq!(RecordType::from_str("any").unwrap(), RecordType::ANY);
859 }
860
861 #[test]
864 fn srv_label_accepts_alphanumeric_and_hyphen() {
865 assert!(is_valid_srv_label("http"));
866 assert!(is_valid_srv_label("ldap-tls"));
867 assert!(is_valid_srv_label("a1"));
868 assert!(is_valid_srv_label("tcp"));
869 }
870
871 #[test]
872 fn srv_label_rejects_empty() {
873 assert!(!is_valid_srv_label(""));
874 }
875
876 #[test]
877 fn srv_label_rejects_leading_or_trailing_hyphen() {
878 assert!(!is_valid_srv_label("-http"));
879 assert!(!is_valid_srv_label("http-"));
880 assert!(!is_valid_srv_label("-"));
881 }
882
883 #[test]
884 fn srv_label_rejects_dots() {
885 assert!(!is_valid_srv_label("http.evil"));
888 assert!(!is_valid_srv_label("a.b"));
889 }
890
891 #[test]
892 fn srv_label_rejects_special_chars() {
893 assert!(!is_valid_srv_label("http evil"));
894 assert!(!is_valid_srv_label("http/evil"));
895 assert!(!is_valid_srv_label("http\0"));
896 assert!(!is_valid_srv_label("http\n"));
897 }
898
899 #[test]
900 fn srv_label_rejects_over_63_chars() {
901 let too_long = "a".repeat(64);
902 assert!(!is_valid_srv_label(&too_long));
903 let exactly_63 = "a".repeat(63);
904 assert!(is_valid_srv_label(&exactly_63));
905 }
906
907 #[test]
910 fn classify_ns_presence_absent_on_empty_ok() {
911 let r: Result<Vec<DnsRecord>> = Ok(vec![]);
913 assert_eq!(classify_ns_presence(&r), DnsPresence::Absent);
914 }
915
916 #[test]
917 fn classify_ns_presence_present_on_records() {
918 let rec = DnsRecord {
919 name: "example.test.".to_string(),
920 record_type: RecordType::NS,
921 ttl: 3600,
922 data: RecordData::NS {
923 nameserver: "ns1.example.net.".to_string(),
924 },
925 };
926 let r: Result<Vec<DnsRecord>> = Ok(vec![rec]);
927 assert_eq!(classify_ns_presence(&r), DnsPresence::Present);
928 }
929
930 #[test]
931 fn classify_ns_presence_unknown_on_error() {
932 let r: Result<Vec<DnsRecord>> = Err(SeerError::DnsError("servfail".to_string()));
933 assert_eq!(classify_ns_presence(&r), DnsPresence::Unknown);
934 }
935
936 #[test]
939 fn reverse_dns_name_formats_ipv4_correctly() {
940 let ip: IpAddr = Ipv4Addr::new(192, 0, 2, 1).into();
941 assert_eq!(reverse_dns_name(&ip), "1.2.0.192.in-addr.arpa");
942 }
943
944 #[test]
945 fn reverse_dns_name_formats_ipv6_correctly() {
946 let ip: IpAddr = Ipv6Addr::LOCALHOST.into();
948 let name = reverse_dns_name(&ip);
949 assert!(
950 name.ends_with(".ip6.arpa"),
951 "must end with .ip6.arpa; got: {}",
952 name
953 );
954 assert!(
956 name.starts_with("1."),
957 "expected '1.' prefix, got: {}",
958 name
959 );
960 assert_eq!(name.len(), 72);
962 }
963
964 #[test]
967 fn resolver_new_has_default_timeout() {
968 let r = DnsResolver::new();
969 assert_eq!(r.timeout, DEFAULT_TIMEOUT);
970 }
971
972 #[test]
973 fn resolver_with_timeout_overrides_default() {
974 let custom = Duration::from_secs(42);
975 let r = DnsResolver::new().with_timeout(custom);
976 assert_eq!(r.timeout, custom);
977 }
978
979 #[test]
980 fn resolver_default_matches_new() {
981 let a = DnsResolver::default();
982 let b = DnsResolver::new();
983 assert_eq!(a.timeout, b.timeout);
984 }
985
986 #[tokio::test]
989 async fn custom_resolver_rejects_invalid_input() {
990 let r = DnsResolver::new();
995 let err = r.create_custom_resolver("..").await.unwrap_err();
996 let msg = err.to_string().to_lowercase();
997 assert!(
998 msg.contains("dns resolution failed") || msg.contains("invalid"),
999 "expected resolution failure, got: {}",
1000 msg
1001 );
1002 }
1003
1004 #[tokio::test]
1005 async fn custom_resolver_rejects_private_ipv4() {
1006 let r = DnsResolver::new();
1009 for reserved in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.169.254"] {
1010 let err = r.create_custom_resolver(reserved).await.unwrap_err();
1011 let msg = err.to_string().to_lowercase();
1012 assert!(
1013 msg.contains("blocked") || msg.contains("reserved"),
1014 "reserved IP {} must be rejected, got error: {}",
1015 reserved,
1016 msg
1017 );
1018 }
1019 }
1020
1021 #[tokio::test]
1022 async fn custom_resolver_rejects_loopback_ipv6() {
1023 let r = DnsResolver::new();
1024 let err = r.create_custom_resolver("::1").await.unwrap_err();
1025 let msg = err.to_string().to_lowercase();
1026 assert!(
1027 msg.contains("blocked") || msg.contains("reserved"),
1028 "::1 must be rejected, got error: {}",
1029 msg
1030 );
1031 }
1032
1033 #[tokio::test]
1034 async fn custom_resolver_accepts_public_ipv4() {
1035 let r = DnsResolver::new();
1037 let result = r.create_custom_resolver("8.8.8.8").await;
1038 assert!(
1039 result.is_ok(),
1040 "8.8.8.8 must be accepted as a public nameserver, got: {:?}",
1041 result.err()
1042 );
1043 }
1044
1045 #[tokio::test]
1051 async fn custom_resolver_rejects_private_ip_for_dot_and_doh() {
1052 let r = DnsResolver::new();
1053 for reserved in [
1054 "tls://127.0.0.1",
1055 "tls://192.168.1.1:853",
1056 "tls://[::1]",
1057 "https://10.0.0.1/dns-query",
1058 "https://169.254.169.254",
1059 "https://[fd00::1]:443/dns-query",
1060 ] {
1061 let err = r.create_custom_resolver(reserved).await.unwrap_err();
1062 let msg = err.to_string().to_lowercase();
1063 assert!(
1064 msg.contains("blocked") || msg.contains("reserved"),
1065 "reserved spec {} must be rejected, got error: {}",
1066 reserved,
1067 msg
1068 );
1069 }
1070 }
1071
1072 #[tokio::test]
1073 async fn custom_resolver_accepts_public_dot_and_doh_literals() {
1074 let r = DnsResolver::new();
1077 for spec in ["tls://1.1.1.1", "https://8.8.8.8/dns-query"] {
1078 let result = r.create_custom_resolver(spec).await;
1079 assert!(
1080 result.is_ok(),
1081 "{} must be accepted, got: {:?}",
1082 spec,
1083 result.err()
1084 );
1085 }
1086 }
1087
1088 #[tokio::test]
1089 async fn custom_resolver_rejects_unknown_scheme() {
1090 let r = DnsResolver::new();
1091 let err = r.create_custom_resolver("ftp://8.8.8.8").await.unwrap_err();
1092 assert!(
1093 matches!(err, SeerError::InvalidInput(_)),
1094 "unknown scheme must be an input error, got: {err:?}"
1095 );
1096 }
1097
1098 fn spec(s: &str) -> NameserverSpec {
1101 NameserverSpec::parse(s).unwrap_or_else(|e| panic!("{s:?} must parse: {e}"))
1102 }
1103
1104 #[test]
1105 fn upstream_config_udp_defaults() {
1106 use hickory_resolver::config::ProtocolConfig;
1107
1108 let ip: IpAddr = "8.8.8.8".parse().unwrap();
1109 let config = build_upstream_config(&spec("8.8.8.8"), &[ip], None);
1110 let servers = config.name_servers();
1111 assert_eq!(servers.len(), 1);
1112 assert_eq!(servers[0].ip, ip);
1113 assert_eq!(servers[0].connections.len(), 1);
1114 assert_eq!(servers[0].connections[0].port, 53);
1115 assert!(matches!(
1116 servers[0].connections[0].protocol,
1117 ProtocolConfig::Udp
1118 ));
1119 }
1120
1121 #[test]
1122 fn upstream_config_udp_explicit_port() {
1123 let ip: IpAddr = "9.9.9.9".parse().unwrap();
1124 let config = build_upstream_config(&spec("9.9.9.9:5353"), &[ip], None);
1125 assert_eq!(config.name_servers()[0].connections[0].port, 5353);
1126 }
1127
1128 #[test]
1129 fn upstream_config_tls_sets_protocol_port_and_server_name() {
1130 use hickory_resolver::config::ProtocolConfig;
1131
1132 let ip: IpAddr = "9.9.9.9".parse().unwrap();
1133 let config = build_upstream_config(&spec("tls://dns.quad9.net"), &[ip], None);
1134 let ns = &config.name_servers()[0];
1135 assert_eq!(ns.ip, ip);
1136 assert_eq!(ns.connections.len(), 1);
1137 assert_eq!(ns.connections[0].port, 853);
1138 match &ns.connections[0].protocol {
1139 ProtocolConfig::Tls { server_name } => {
1140 assert_eq!(&**server_name, "dns.quad9.net");
1141 }
1142 other => panic!("expected Tls protocol, got {other:?}"),
1143 }
1144 }
1145
1146 #[test]
1147 fn upstream_config_tls_ip_literal_uses_ip_as_server_name() {
1148 use hickory_resolver::config::ProtocolConfig;
1149
1150 let ip: IpAddr = "1.1.1.1".parse().unwrap();
1151 let config = build_upstream_config(&spec("tls://1.1.1.1"), &[ip], None);
1152 match &config.name_servers()[0].connections[0].protocol {
1153 ProtocolConfig::Tls { server_name } => assert_eq!(&**server_name, "1.1.1.1"),
1154 other => panic!("expected Tls protocol, got {other:?}"),
1155 }
1156 }
1157
1158 #[test]
1159 fn upstream_config_https_sets_protocol_port_path_and_server_name() {
1160 use hickory_resolver::config::ProtocolConfig;
1161
1162 let ip: IpAddr = "104.16.248.249".parse().unwrap();
1163 let config = build_upstream_config(&spec("https://cloudflare-dns.com"), &[ip], None);
1164 let ns = &config.name_servers()[0];
1165 assert_eq!(ns.connections[0].port, 443);
1166 match &ns.connections[0].protocol {
1167 ProtocolConfig::Https { server_name, path } => {
1168 assert_eq!(&**server_name, "cloudflare-dns.com");
1169 assert_eq!(&**path, "/dns-query");
1170 }
1171 other => panic!("expected Https protocol, got {other:?}"),
1172 }
1173 }
1174
1175 #[test]
1176 fn upstream_config_https_custom_port_and_path() {
1177 use hickory_resolver::config::ProtocolConfig;
1178
1179 let ip: IpAddr = "8.8.8.8".parse().unwrap();
1180 let config = build_upstream_config(&spec("https://dns.google:8443/resolve"), &[ip], None);
1181 let ns = &config.name_servers()[0];
1182 assert_eq!(ns.connections[0].port, 8443);
1183 match &ns.connections[0].protocol {
1184 ProtocolConfig::Https { server_name, path } => {
1185 assert_eq!(&**server_name, "dns.google");
1186 assert_eq!(&**path, "/resolve");
1187 }
1188 other => panic!("expected Https protocol, got {other:?}"),
1189 }
1190 }
1191
1192 #[test]
1193 fn upstream_config_multiple_ips_share_spec() {
1194 use hickory_resolver::config::ProtocolConfig;
1197
1198 let ips: Vec<IpAddr> = vec![
1199 "9.9.9.9".parse().unwrap(),
1200 "149.112.112.112".parse().unwrap(),
1201 ];
1202 let config = build_upstream_config(&spec("tls://dns.quad9.net"), &ips, None);
1203 let servers = config.name_servers();
1204 assert_eq!(servers.len(), 2);
1205 for (ns, expected_ip) in servers.iter().zip(&ips) {
1206 assert_eq!(&ns.ip, expected_ip);
1207 assert_eq!(ns.connections[0].port, 853);
1208 assert!(matches!(
1209 &ns.connections[0].protocol,
1210 ProtocolConfig::Tls { server_name } if &**server_name == "dns.quad9.net"
1211 ));
1212 }
1213 }
1214
1215 #[test]
1216 fn upstream_config_test_port_override_wins() {
1217 let ip: IpAddr = "127.0.0.1".parse().unwrap();
1219 let config = build_upstream_config(&spec("127.0.0.1"), &[ip], Some(9999));
1220 assert_eq!(config.name_servers()[0].connections[0].port, 9999);
1221 }
1222
1223 #[tokio::test]
1226 #[ignore = "live network — DoT query against Cloudflare"]
1227 async fn live_resolve_over_dot() {
1228 let r = DnsResolver::new();
1229 let records = r
1230 .resolve("example.com", RecordType::A, Some("tls://1.1.1.1"))
1231 .await
1232 .expect("DoT lookup should succeed");
1233 assert!(!records.is_empty(), "expected A records over DoT");
1234 }
1235
1236 #[tokio::test]
1237 #[ignore = "live network — DoH query against Cloudflare"]
1238 async fn live_resolve_over_doh() {
1239 let r = DnsResolver::new();
1240 let records = r
1241 .resolve(
1242 "example.com",
1243 RecordType::A,
1244 Some("https://cloudflare-dns.com/dns-query"),
1245 )
1246 .await
1247 .expect("DoH lookup should succeed");
1248 assert!(!records.is_empty(), "expected A records over DoH");
1249 }
1250
1251 #[tokio::test]
1254 async fn resolve_srv_rejects_invalid_service_label() {
1255 let r = DnsResolver::new();
1256 let result = r.resolve_srv("http.evil", "tcp", "example.com", None).await;
1258 assert!(result.is_err());
1259 let msg = result.unwrap_err().to_string().to_lowercase();
1260 assert!(
1261 msg.contains("invalid srv service"),
1262 "expected SRV service validation error, got: {}",
1263 msg
1264 );
1265 }
1266
1267 #[tokio::test]
1268 async fn resolve_srv_rejects_invalid_protocol_label() {
1269 let r = DnsResolver::new();
1270 let result = r.resolve_srv("http", "tcp.evil", "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 protocol"),
1275 "expected SRV protocol validation error, got: {}",
1276 msg
1277 );
1278 }
1279
1280 #[tokio::test]
1281 async fn resolve_srv_normalizes_and_validates_domain_input() {
1282 let r = DnsResolver::new();
1288 let result = r
1289 .resolve_srv("http", "tcp", "not a valid domain", None)
1290 .await;
1291 assert!(
1292 matches!(result, Err(SeerError::InvalidDomain(_))),
1293 "expected InvalidDomain from domain validation, got: {result:?}"
1294 );
1295 }
1296
1297 #[tokio::test]
1300 async fn resolve_normalizes_uppercase_domain_input() {
1301 let r = DnsResolver::new();
1306 let result = r.resolve(".bad.example", RecordType::A, None).await;
1307 assert!(result.is_err(), "leading-dot domain must be rejected");
1308 }
1309
1310 #[test]
1315 fn parse_srv_query_extracts_service_proto_and_name() {
1316 assert_eq!(
1317 parse_srv_query("_sip._tcp.example.com"),
1318 Some((
1319 "sip".to_string(),
1320 "tcp".to_string(),
1321 "example.com".to_string()
1322 ))
1323 );
1324 }
1325
1326 #[test]
1327 fn parse_srv_query_keeps_multilabel_domain() {
1328 assert_eq!(
1329 parse_srv_query("_sip._tcp.sip.voice.google.com"),
1330 Some((
1331 "sip".to_string(),
1332 "tcp".to_string(),
1333 "sip.voice.google.com".to_string()
1334 ))
1335 );
1336 }
1337
1338 #[test]
1339 fn parse_srv_query_rejects_bare_domain() {
1340 assert_eq!(parse_srv_query("example.com"), None);
1341 }
1342
1343 #[test]
1344 fn parse_srv_query_rejects_missing_proto_label() {
1345 assert_eq!(parse_srv_query("_sip.example.com"), None);
1347 }
1348
1349 #[tokio::test]
1350 async fn resolve_rejects_bare_domain_for_srv_as_input_error() {
1351 let r = DnsResolver::new();
1356 let err = r
1357 .resolve("example.com", RecordType::SRV, None)
1358 .await
1359 .expect_err("bare-domain SRV must error");
1360 assert!(
1361 matches!(err, SeerError::InvalidInput(_)),
1362 "bare-domain SRV should be an input error, got: {err:?}"
1363 );
1364 assert!(err.to_string().contains("_service._proto"));
1365 }
1366
1367 #[tokio::test]
1368 #[ignore = "live network"]
1369 async fn resolve_srv_via_dig_style_name_returns_records() {
1370 let r = DnsResolver::new();
1373 let records = r
1374 .resolve("_caldavs._tcp.google.com", RecordType::SRV, None)
1375 .await
1376 .expect("dig-style SRV lookup should succeed");
1377 assert!(!records.is_empty(), "expected SRV records");
1378 assert!(records.iter().all(|r| r.record_type == RecordType::SRV));
1379 }
1380
1381 #[tokio::test]
1382 #[ignore = "live network"]
1383 async fn resolve_naptr_returns_records() {
1384 let r = DnsResolver::new();
1386 let records = r
1387 .resolve("sip2sip.info", RecordType::NAPTR, None)
1388 .await
1389 .expect("NAPTR lookup should succeed");
1390 assert!(!records.is_empty(), "expected NAPTR records");
1391 assert!(records.iter().all(|r| r.record_type == RecordType::NAPTR));
1392 }
1393
1394 #[test]
1397 fn prepare_query_passes_ipv6_literal_through_for_ptr() {
1398 let out = prepare_query("2606:4700:4700::1111", RecordType::PTR).unwrap();
1404 assert_eq!(out, "2606:4700:4700::1111");
1405 }
1406
1407 #[test]
1408 fn prepare_query_passes_ipv6_loopback_through_for_ptr() {
1409 let out = prepare_query("::1", RecordType::PTR).unwrap();
1410 assert_eq!(out, "::1");
1411 }
1412
1413 #[test]
1414 fn prepare_query_passes_ipv4_literal_through_for_ptr() {
1415 let out = prepare_query("8.8.8.8", RecordType::PTR).unwrap();
1416 assert_eq!(out, "8.8.8.8");
1417 }
1418
1419 #[test]
1420 fn prepare_query_normalizes_non_ip_ptr_names() {
1421 let out = prepare_query("1.1.1.1.in-addr.arpa", RecordType::PTR).unwrap();
1423 assert_eq!(out, "1.1.1.1.in-addr.arpa");
1424 }
1425
1426 #[test]
1427 fn prepare_query_normalizes_domains_for_non_ptr() {
1428 let out = prepare_query("HTTPS://WWW.Example.com/path", RecordType::A).unwrap();
1429 assert_eq!(out, "example.com");
1430 }
1431
1432 use crate::dns::test_support::{mock_dns_resolver, spawn_mock_dns, MockMode};
1443
1444 async fn mock_zone_lookup(record_type: RecordType, domain: &str) -> Vec<DnsRecord> {
1445 let port = spawn_mock_dns(MockMode::Zone).await;
1446 mock_dns_resolver(port)
1447 .resolve(domain, record_type, Some("127.0.0.1"))
1448 .await
1449 .unwrap_or_else(|e| panic!("{record_type} lookup against mock must succeed: {e}"))
1450 }
1451
1452 #[tokio::test]
1453 async fn mock_resolve_a_returns_addresses() {
1454 let records = mock_zone_lookup(RecordType::A, "seer.test").await;
1455 assert_eq!(records.len(), 2);
1456 assert!(records.iter().all(|r| r.record_type == RecordType::A));
1457 assert_eq!(records[0].name, "seer.test");
1458 assert_eq!(records[0].ttl, 300);
1459 let addresses: Vec<String> = records
1460 .iter()
1461 .map(|r| match &r.data {
1462 RecordData::A { address } => address.clone(),
1463 other => panic!("expected A data, got {other:?}"),
1464 })
1465 .collect();
1466 assert!(addresses.contains(&"192.0.2.1".to_string()));
1467 assert!(addresses.contains(&"192.0.2.2".to_string()));
1468 }
1469
1470 #[tokio::test]
1471 async fn mock_resolve_mx_sorts_by_preference() {
1472 let records = mock_zone_lookup(RecordType::MX, "seer.test").await;
1473 let prefs: Vec<u16> = records
1474 .iter()
1475 .map(|r| match &r.data {
1476 RecordData::MX { preference, .. } => *preference,
1477 other => panic!("expected MX data, got {other:?}"),
1478 })
1479 .collect();
1480 assert_eq!(prefs, vec![10, 20, 30]);
1482 assert!(matches!(
1483 &records[0].data,
1484 RecordData::MX { exchange, .. } if exchange == "a.mail.seer.test."
1485 ));
1486 }
1487
1488 #[tokio::test]
1489 async fn mock_resolve_txt_joins_character_strings() {
1490 let records = mock_zone_lookup(RecordType::TXT, "seer.test").await;
1491 assert_eq!(records.len(), 1);
1492 match &records[0].data {
1493 RecordData::TXT { text } => assert_eq!(text, "v=spf1 -all"),
1494 other => panic!("expected TXT data, got {other:?}"),
1495 }
1496 }
1497
1498 #[tokio::test]
1499 async fn mock_resolve_soa_maps_all_fields() {
1500 let records = mock_zone_lookup(RecordType::SOA, "seer.test").await;
1501 assert_eq!(records.len(), 1);
1502 match &records[0].data {
1503 RecordData::SOA {
1504 mname,
1505 rname,
1506 serial,
1507 refresh,
1508 retry,
1509 expire,
1510 minimum,
1511 } => {
1512 assert_eq!(mname, "ns1.seer.test.");
1513 assert_eq!(rname, "hostmaster.seer.test.");
1514 assert_eq!(*serial, 2026070101);
1515 assert_eq!(*refresh, 7200);
1516 assert_eq!(*retry, 3600);
1517 assert_eq!(*expire, 1209600);
1518 assert_eq!(*minimum, 300);
1519 }
1520 other => panic!("expected SOA data, got {other:?}"),
1521 }
1522 }
1523
1524 #[tokio::test]
1525 async fn mock_resolve_caa_maps_flags_tag_and_value() {
1526 let records = mock_zone_lookup(RecordType::CAA, "seer.test").await;
1527 assert_eq!(records.len(), 2);
1528 let by_tag = |wanted: &str| {
1529 records
1530 .iter()
1531 .find_map(|r| match &r.data {
1532 RecordData::CAA { flags, tag, value } if tag == wanted => {
1533 Some((*flags, value.clone()))
1534 }
1535 _ => None,
1536 })
1537 .unwrap_or_else(|| panic!("expected a CAA record with tag {wanted}"))
1538 };
1539 assert_eq!(by_tag("issue"), (0, "letsencrypt.org".to_string()));
1541 assert_eq!(
1542 by_tag("iodef"),
1543 (128, "mailto:security@seer.test".to_string())
1544 );
1545 }
1546
1547 #[tokio::test]
1548 async fn mock_resolve_tlsa_hex_encodes_cert_data() {
1549 let records = mock_zone_lookup(RecordType::TLSA, "_443._tcp.seer.test").await;
1550 assert_eq!(records.len(), 1);
1551 match &records[0].data {
1552 RecordData::TLSA {
1553 cert_usage,
1554 selector,
1555 matching,
1556 cert_data,
1557 } => {
1558 assert_eq!((*cert_usage, *selector, *matching), (3, 1, 1));
1559 assert_eq!(cert_data, "ABCD01");
1560 }
1561 other => panic!("expected TLSA data, got {other:?}"),
1562 }
1563 }
1564
1565 #[tokio::test]
1566 async fn mock_resolve_sshfp_hex_encodes_fingerprint() {
1567 let records = mock_zone_lookup(RecordType::SSHFP, "seer.test").await;
1568 assert_eq!(records.len(), 1);
1569 match &records[0].data {
1570 RecordData::SSHFP {
1571 algorithm,
1572 fingerprint_type,
1573 fingerprint,
1574 } => {
1575 assert_eq!((*algorithm, *fingerprint_type), (4, 2));
1576 assert_eq!(fingerprint, "DEADBEEF");
1577 }
1578 other => panic!("expected SSHFP data, got {other:?}"),
1579 }
1580 }
1581
1582 #[tokio::test]
1583 async fn mock_resolve_naptr_decodes_character_strings() {
1584 let records = mock_zone_lookup(RecordType::NAPTR, "seer.test").await;
1585 assert_eq!(records.len(), 1);
1586 match &records[0].data {
1587 RecordData::NAPTR {
1588 order,
1589 preference,
1590 flags,
1591 services,
1592 regexp,
1593 replacement,
1594 } => {
1595 assert_eq!((*order, *preference), (100, 50));
1596 assert_eq!(flags, "U");
1597 assert_eq!(services, "E2U+sip");
1598 assert_eq!(regexp, "!^.*$!sip:info@seer.test!");
1599 assert_eq!(replacement, ".");
1600 }
1601 other => panic!("expected NAPTR data, got {other:?}"),
1602 }
1603 }
1604
1605 #[tokio::test]
1606 async fn mock_resolve_srv_via_dig_style_name() {
1607 let records = mock_zone_lookup(RecordType::SRV, "_sip._tcp.seer.test").await;
1608 assert_eq!(records.len(), 1);
1609 assert_eq!(records[0].name, "_sip._tcp.seer.test");
1610 match &records[0].data {
1611 RecordData::SRV {
1612 priority,
1613 weight,
1614 port,
1615 target,
1616 } => {
1617 assert_eq!((*priority, *weight, *port), (10, 5, 5060));
1618 assert_eq!(target, "sipserver.seer.test.");
1619 }
1620 other => panic!("expected SRV data, got {other:?}"),
1621 }
1622 }
1623
1624 #[tokio::test]
1625 async fn mock_resolve_ptr_transforms_ip_literal() {
1626 let records = mock_zone_lookup(RecordType::PTR, "192.0.2.1").await;
1627 assert_eq!(records.len(), 1);
1628 assert_eq!(records[0].name, "1.2.0.192.in-addr.arpa");
1630 assert!(matches!(
1631 &records[0].data,
1632 RecordData::PTR { target } if target == "ptr.seer.test."
1633 ));
1634 }
1635
1636 #[tokio::test]
1637 async fn mock_resolve_any_aggregates_multiple_types() {
1638 let records = mock_zone_lookup(RecordType::ANY, "seer.test").await;
1639 for expected in [
1641 RecordType::A,
1642 RecordType::AAAA,
1643 RecordType::MX,
1644 RecordType::NS,
1645 RecordType::TXT,
1646 RecordType::SOA,
1647 RecordType::CAA,
1648 ] {
1649 assert!(
1650 records.iter().any(|r| r.record_type == expected),
1651 "ANY must include {expected} records"
1652 );
1653 }
1654 assert_eq!(records.len(), 11);
1656 }
1657
1658 #[tokio::test]
1659 async fn mock_nodata_folds_to_empty_and_classifies_absent() {
1660 let port = spawn_mock_dns(MockMode::NoData).await;
1661 let result = mock_dns_resolver(port)
1662 .resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
1663 .await;
1664 assert!(
1665 matches!(&result, Ok(records) if records.is_empty()),
1666 "NODATA must fold to Ok(vec![]), got: {result:?}"
1667 );
1668 assert_eq!(classify_ns_presence(&result), DnsPresence::Absent);
1669 }
1670
1671 #[tokio::test]
1672 async fn mock_nxdomain_folds_to_empty_and_classifies_absent() {
1673 let port = spawn_mock_dns(MockMode::Nxdomain).await;
1674 let result = mock_dns_resolver(port)
1675 .resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
1676 .await;
1677 assert!(
1678 matches!(&result, Ok(records) if records.is_empty()),
1679 "NXDOMAIN must fold to Ok(vec![]), got: {result:?}"
1680 );
1681 assert_eq!(classify_ns_presence(&result), DnsPresence::Absent);
1682 }
1683
1684 #[tokio::test]
1685 async fn mock_timeout_errors_and_classifies_unknown() {
1686 let port = spawn_mock_dns(MockMode::Ignore).await;
1687 let resolver = DnsResolver::new()
1689 .with_timeout(Duration::from_millis(200))
1690 .allowing_private_hosts()
1691 .with_port(port);
1692 let result = resolver
1693 .resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
1694 .await;
1695 match &result {
1696 Err(SeerError::DnsError(_)) => {}
1697 other => panic!("unanswered query must surface a DnsError, got: {other:?}"),
1698 }
1699 assert_eq!(classify_ns_presence(&result), DnsPresence::Unknown);
1700 }
1701
1702 #[tokio::test]
1703 async fn resolve_type_rejects_composite_types_consistently() {
1704 let r = DnsResolver::new();
1709 for composite in [RecordType::SRV, RecordType::ANY] {
1710 let err = r
1711 .resolve_type(&r.default_resolver, "seer.test", composite)
1712 .await
1713 .expect_err("composite types must be rejected by resolve_type");
1714 assert_eq!(
1715 err.to_string(),
1716 unsupported_record_type(composite).to_string()
1717 );
1718 }
1719 }
1720}