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 hickory_resolver::proto::op::{Message, OpCode, ResponseCode};
1443 use hickory_resolver::proto::rr::rdata as wire;
1444 use hickory_resolver::proto::rr::rdata::{sshfp, tlsa};
1445 use hickory_resolver::proto::rr::{Name, Record};
1446 use tokio::net::UdpSocket;
1447
1448 #[derive(Clone, Copy)]
1450 enum MockMode {
1451 Zone,
1453 Nxdomain,
1455 NoData,
1457 Ignore,
1459 }
1460
1461 fn name(s: &str) -> Name {
1462 Name::from_ascii(s).expect("valid test name")
1463 }
1464
1465 fn zone_answers(qname: &str, qtype: HickoryRecordType) -> Vec<HickoryRData> {
1468 match (qname.trim_end_matches('.'), qtype) {
1469 ("seer.test", HickoryRecordType::A) => vec![
1470 HickoryRData::A(wire::A(Ipv4Addr::new(192, 0, 2, 1))),
1471 HickoryRData::A(wire::A(Ipv4Addr::new(192, 0, 2, 2))),
1472 ],
1473 ("seer.test", HickoryRecordType::AAAA) => vec![HickoryRData::AAAA(wire::AAAA(
1474 "2001:db8::1".parse().expect("valid IPv6 literal"),
1475 ))],
1476 ("seer.test", HickoryRecordType::MX) => vec![
1478 HickoryRData::MX(wire::MX::new(30, name("c.mail.seer.test."))),
1479 HickoryRData::MX(wire::MX::new(10, name("a.mail.seer.test."))),
1480 HickoryRData::MX(wire::MX::new(20, name("b.mail.seer.test."))),
1481 ],
1482 ("seer.test", HickoryRecordType::NS) => {
1483 vec![HickoryRData::NS(wire::NS(name("ns1.seer.test.")))]
1484 }
1485 ("seer.test", HickoryRecordType::TXT) => vec![HickoryRData::TXT(wire::TXT::new(vec![
1487 "v=spf1 ".to_string(),
1488 "-all".to_string(),
1489 ]))],
1490 ("seer.test", HickoryRecordType::SOA) => vec![HickoryRData::SOA(wire::SOA::new(
1491 name("ns1.seer.test."),
1492 name("hostmaster.seer.test."),
1493 2026070101,
1494 7200,
1495 3600,
1496 1209600,
1497 300,
1498 ))],
1499 ("seer.test", HickoryRecordType::CAA) => vec![
1502 HickoryRData::CAA(CAA::new_issue(false, Some(name("letsencrypt.org")), vec![])),
1503 HickoryRData::CAA(CAA::new_iodef(
1504 true,
1505 url::Url::parse("mailto:security@seer.test").expect("valid iodef URL"),
1506 )),
1507 ],
1508 ("_443._tcp.seer.test", HickoryRecordType::TLSA) => {
1509 vec![HickoryRData::TLSA(wire::TLSA::new(
1510 tlsa::CertUsage::from(3),
1511 tlsa::Selector::from(1),
1512 tlsa::Matching::from(1),
1513 vec![0xAB, 0xCD, 0x01],
1514 ))]
1515 }
1516 ("seer.test", HickoryRecordType::SSHFP) => {
1517 vec![HickoryRData::SSHFP(wire::SSHFP::new(
1518 sshfp::Algorithm::from(4),
1519 sshfp::FingerprintType::from(2),
1520 vec![0xDE, 0xAD, 0xBE, 0xEF],
1521 ))]
1522 }
1523 ("seer.test", HickoryRecordType::NAPTR) => {
1524 vec![HickoryRData::NAPTR(wire::NAPTR::new(
1525 100,
1526 50,
1527 b"U".to_vec().into_boxed_slice(),
1528 b"E2U+sip".to_vec().into_boxed_slice(),
1529 b"!^.*$!sip:info@seer.test!".to_vec().into_boxed_slice(),
1530 Name::root(),
1531 ))]
1532 }
1533 ("_sip._tcp.seer.test", HickoryRecordType::SRV) => vec![HickoryRData::SRV(
1534 wire::SRV::new(10, 5, 5060, name("sipserver.seer.test.")),
1535 )],
1536 ("1.2.0.192.in-addr.arpa", HickoryRecordType::PTR) => {
1537 vec![HickoryRData::PTR(wire::PTR(name("ptr.seer.test.")))]
1538 }
1539 _ => vec![],
1540 }
1541 }
1542
1543 async fn spawn_mock_dns(mode: MockMode) -> u16 {
1547 let socket = UdpSocket::bind("127.0.0.1:0").await.expect("bind mock DNS");
1548 let port = socket.local_addr().expect("mock DNS local addr").port();
1549 tokio::spawn(async move {
1550 let mut buf = [0u8; 4096];
1551 loop {
1552 let Ok((len, src)) = socket.recv_from(&mut buf).await else {
1553 return;
1554 };
1555 if matches!(mode, MockMode::Ignore) {
1556 continue;
1557 }
1558 let Ok(request) = Message::from_vec(&buf[..len]) else {
1559 continue;
1560 };
1561 let mut response = Message::response(request.metadata.id, OpCode::Query);
1562 response.metadata.recursion_desired = request.metadata.recursion_desired;
1563 response.metadata.recursion_available = true;
1564 for query in &request.queries {
1567 response.add_query(query.clone());
1568 }
1569 match mode {
1570 MockMode::Zone => {
1571 if let Some(query) = request.queries.first() {
1572 for rdata in zone_answers(&query.name.to_string(), query.query_type) {
1573 response.add_answer(Record::from_rdata(
1574 query.name.clone(),
1575 300,
1576 rdata,
1577 ));
1578 }
1579 }
1580 }
1581 MockMode::Nxdomain => {
1582 response.metadata.response_code = ResponseCode::NXDomain;
1583 }
1584 MockMode::NoData | MockMode::Ignore => {}
1585 }
1586 let Ok(bytes) = response.to_vec() else {
1587 continue;
1588 };
1589 let _ = socket.send_to(&bytes, src).await;
1590 }
1591 });
1592 port
1593 }
1594
1595 fn mock_dns_resolver(port: u16) -> DnsResolver {
1596 DnsResolver::new()
1597 .with_timeout(Duration::from_millis(500))
1598 .allowing_private_hosts()
1599 .with_port(port)
1600 }
1601
1602 async fn mock_zone_lookup(record_type: RecordType, domain: &str) -> Vec<DnsRecord> {
1603 let port = spawn_mock_dns(MockMode::Zone).await;
1604 mock_dns_resolver(port)
1605 .resolve(domain, record_type, Some("127.0.0.1"))
1606 .await
1607 .unwrap_or_else(|e| panic!("{record_type} lookup against mock must succeed: {e}"))
1608 }
1609
1610 #[tokio::test]
1611 async fn mock_resolve_a_returns_addresses() {
1612 let records = mock_zone_lookup(RecordType::A, "seer.test").await;
1613 assert_eq!(records.len(), 2);
1614 assert!(records.iter().all(|r| r.record_type == RecordType::A));
1615 assert_eq!(records[0].name, "seer.test");
1616 assert_eq!(records[0].ttl, 300);
1617 let addresses: Vec<String> = records
1618 .iter()
1619 .map(|r| match &r.data {
1620 RecordData::A { address } => address.clone(),
1621 other => panic!("expected A data, got {other:?}"),
1622 })
1623 .collect();
1624 assert!(addresses.contains(&"192.0.2.1".to_string()));
1625 assert!(addresses.contains(&"192.0.2.2".to_string()));
1626 }
1627
1628 #[tokio::test]
1629 async fn mock_resolve_mx_sorts_by_preference() {
1630 let records = mock_zone_lookup(RecordType::MX, "seer.test").await;
1631 let prefs: Vec<u16> = records
1632 .iter()
1633 .map(|r| match &r.data {
1634 RecordData::MX { preference, .. } => *preference,
1635 other => panic!("expected MX data, got {other:?}"),
1636 })
1637 .collect();
1638 assert_eq!(prefs, vec![10, 20, 30]);
1640 assert!(matches!(
1641 &records[0].data,
1642 RecordData::MX { exchange, .. } if exchange == "a.mail.seer.test."
1643 ));
1644 }
1645
1646 #[tokio::test]
1647 async fn mock_resolve_txt_joins_character_strings() {
1648 let records = mock_zone_lookup(RecordType::TXT, "seer.test").await;
1649 assert_eq!(records.len(), 1);
1650 match &records[0].data {
1651 RecordData::TXT { text } => assert_eq!(text, "v=spf1 -all"),
1652 other => panic!("expected TXT data, got {other:?}"),
1653 }
1654 }
1655
1656 #[tokio::test]
1657 async fn mock_resolve_soa_maps_all_fields() {
1658 let records = mock_zone_lookup(RecordType::SOA, "seer.test").await;
1659 assert_eq!(records.len(), 1);
1660 match &records[0].data {
1661 RecordData::SOA {
1662 mname,
1663 rname,
1664 serial,
1665 refresh,
1666 retry,
1667 expire,
1668 minimum,
1669 } => {
1670 assert_eq!(mname, "ns1.seer.test.");
1671 assert_eq!(rname, "hostmaster.seer.test.");
1672 assert_eq!(*serial, 2026070101);
1673 assert_eq!(*refresh, 7200);
1674 assert_eq!(*retry, 3600);
1675 assert_eq!(*expire, 1209600);
1676 assert_eq!(*minimum, 300);
1677 }
1678 other => panic!("expected SOA data, got {other:?}"),
1679 }
1680 }
1681
1682 #[tokio::test]
1683 async fn mock_resolve_caa_maps_flags_tag_and_value() {
1684 let records = mock_zone_lookup(RecordType::CAA, "seer.test").await;
1685 assert_eq!(records.len(), 2);
1686 let by_tag = |wanted: &str| {
1687 records
1688 .iter()
1689 .find_map(|r| match &r.data {
1690 RecordData::CAA { flags, tag, value } if tag == wanted => {
1691 Some((*flags, value.clone()))
1692 }
1693 _ => None,
1694 })
1695 .unwrap_or_else(|| panic!("expected a CAA record with tag {wanted}"))
1696 };
1697 assert_eq!(by_tag("issue"), (0, "letsencrypt.org".to_string()));
1699 assert_eq!(
1700 by_tag("iodef"),
1701 (128, "mailto:security@seer.test".to_string())
1702 );
1703 }
1704
1705 #[tokio::test]
1706 async fn mock_resolve_tlsa_hex_encodes_cert_data() {
1707 let records = mock_zone_lookup(RecordType::TLSA, "_443._tcp.seer.test").await;
1708 assert_eq!(records.len(), 1);
1709 match &records[0].data {
1710 RecordData::TLSA {
1711 cert_usage,
1712 selector,
1713 matching,
1714 cert_data,
1715 } => {
1716 assert_eq!((*cert_usage, *selector, *matching), (3, 1, 1));
1717 assert_eq!(cert_data, "ABCD01");
1718 }
1719 other => panic!("expected TLSA data, got {other:?}"),
1720 }
1721 }
1722
1723 #[tokio::test]
1724 async fn mock_resolve_sshfp_hex_encodes_fingerprint() {
1725 let records = mock_zone_lookup(RecordType::SSHFP, "seer.test").await;
1726 assert_eq!(records.len(), 1);
1727 match &records[0].data {
1728 RecordData::SSHFP {
1729 algorithm,
1730 fingerprint_type,
1731 fingerprint,
1732 } => {
1733 assert_eq!((*algorithm, *fingerprint_type), (4, 2));
1734 assert_eq!(fingerprint, "DEADBEEF");
1735 }
1736 other => panic!("expected SSHFP data, got {other:?}"),
1737 }
1738 }
1739
1740 #[tokio::test]
1741 async fn mock_resolve_naptr_decodes_character_strings() {
1742 let records = mock_zone_lookup(RecordType::NAPTR, "seer.test").await;
1743 assert_eq!(records.len(), 1);
1744 match &records[0].data {
1745 RecordData::NAPTR {
1746 order,
1747 preference,
1748 flags,
1749 services,
1750 regexp,
1751 replacement,
1752 } => {
1753 assert_eq!((*order, *preference), (100, 50));
1754 assert_eq!(flags, "U");
1755 assert_eq!(services, "E2U+sip");
1756 assert_eq!(regexp, "!^.*$!sip:info@seer.test!");
1757 assert_eq!(replacement, ".");
1758 }
1759 other => panic!("expected NAPTR data, got {other:?}"),
1760 }
1761 }
1762
1763 #[tokio::test]
1764 async fn mock_resolve_srv_via_dig_style_name() {
1765 let records = mock_zone_lookup(RecordType::SRV, "_sip._tcp.seer.test").await;
1766 assert_eq!(records.len(), 1);
1767 assert_eq!(records[0].name, "_sip._tcp.seer.test");
1768 match &records[0].data {
1769 RecordData::SRV {
1770 priority,
1771 weight,
1772 port,
1773 target,
1774 } => {
1775 assert_eq!((*priority, *weight, *port), (10, 5, 5060));
1776 assert_eq!(target, "sipserver.seer.test.");
1777 }
1778 other => panic!("expected SRV data, got {other:?}"),
1779 }
1780 }
1781
1782 #[tokio::test]
1783 async fn mock_resolve_ptr_transforms_ip_literal() {
1784 let records = mock_zone_lookup(RecordType::PTR, "192.0.2.1").await;
1785 assert_eq!(records.len(), 1);
1786 assert_eq!(records[0].name, "1.2.0.192.in-addr.arpa");
1788 assert!(matches!(
1789 &records[0].data,
1790 RecordData::PTR { target } if target == "ptr.seer.test."
1791 ));
1792 }
1793
1794 #[tokio::test]
1795 async fn mock_resolve_any_aggregates_multiple_types() {
1796 let records = mock_zone_lookup(RecordType::ANY, "seer.test").await;
1797 for expected in [
1799 RecordType::A,
1800 RecordType::AAAA,
1801 RecordType::MX,
1802 RecordType::NS,
1803 RecordType::TXT,
1804 RecordType::SOA,
1805 RecordType::CAA,
1806 ] {
1807 assert!(
1808 records.iter().any(|r| r.record_type == expected),
1809 "ANY must include {expected} records"
1810 );
1811 }
1812 assert_eq!(records.len(), 11);
1814 }
1815
1816 #[tokio::test]
1817 async fn mock_nodata_folds_to_empty_and_classifies_absent() {
1818 let port = spawn_mock_dns(MockMode::NoData).await;
1819 let result = mock_dns_resolver(port)
1820 .resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
1821 .await;
1822 assert!(
1823 matches!(&result, Ok(records) if records.is_empty()),
1824 "NODATA must fold to Ok(vec![]), got: {result:?}"
1825 );
1826 assert_eq!(classify_ns_presence(&result), DnsPresence::Absent);
1827 }
1828
1829 #[tokio::test]
1830 async fn mock_nxdomain_folds_to_empty_and_classifies_absent() {
1831 let port = spawn_mock_dns(MockMode::Nxdomain).await;
1832 let result = mock_dns_resolver(port)
1833 .resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
1834 .await;
1835 assert!(
1836 matches!(&result, Ok(records) if records.is_empty()),
1837 "NXDOMAIN must fold to Ok(vec![]), got: {result:?}"
1838 );
1839 assert_eq!(classify_ns_presence(&result), DnsPresence::Absent);
1840 }
1841
1842 #[tokio::test]
1843 async fn mock_timeout_errors_and_classifies_unknown() {
1844 let port = spawn_mock_dns(MockMode::Ignore).await;
1845 let resolver = DnsResolver::new()
1847 .with_timeout(Duration::from_millis(200))
1848 .allowing_private_hosts()
1849 .with_port(port);
1850 let result = resolver
1851 .resolve("seer.test", RecordType::NS, Some("127.0.0.1"))
1852 .await;
1853 match &result {
1854 Err(SeerError::DnsError(_)) => {}
1855 other => panic!("unanswered query must surface a DnsError, got: {other:?}"),
1856 }
1857 assert_eq!(classify_ns_presence(&result), DnsPresence::Unknown);
1858 }
1859
1860 #[tokio::test]
1861 async fn resolve_type_rejects_composite_types_consistently() {
1862 let r = DnsResolver::new();
1867 for composite in [RecordType::SRV, RecordType::ANY] {
1868 let err = r
1869 .resolve_type(&r.default_resolver, "seer.test", composite)
1870 .await
1871 .expect_err("composite types must be rejected by resolve_type");
1872 assert_eq!(
1873 err.to_string(),
1874 unsupported_record_type(composite).to_string()
1875 );
1876 }
1877 }
1878}