1use async_trait::async_trait;
4use hickory_resolver::TokioResolver;
5use hickory_resolver::config::{
6 CLOUDFLARE, GOOGLE, NameServerConfig, QUAD9, ResolverConfig, ResolverOpts,
7};
8use hickory_resolver::net::runtime::TokioRuntimeProvider;
9use hickory_resolver::net::{DnsError as HickoryDnsError, NetError, NoRecords as HickoryNoRecords};
10use hickory_resolver::proto::op::ResponseCode;
11use hickory_resolver::proto::rr::RData;
12use std::fmt;
13use std::net::{IpAddr, Ipv4Addr};
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16#[non_exhaustive]
17pub enum DnsResolverConfig {
18 #[default]
20 System,
21 Cloudflare,
23 CloudflareTls,
25 Google,
27 GoogleTls,
29 Quad9,
31 Quad9Tls,
33}
34
35impl DnsResolverConfig {
36 pub(crate) fn to_resolver_config(self) -> Result<(ResolverConfig, ResolverOpts), DnsError> {
42 match self {
43 Self::System => hickory_resolver::system_conf::read_system_conf().map_err(|e| {
44 DnsError::LookupFailed {
45 fqdn: "(system config)".to_string(),
46 reason: format!("failed to read system DNS config: {e}"),
47 }
48 }),
49 Self::Cloudflare => Ok((
50 ResolverConfig::udp_and_tcp(&CLOUDFLARE),
51 ResolverOpts::default(),
52 )),
53 Self::CloudflareTls => Ok((ResolverConfig::tls(&CLOUDFLARE), ResolverOpts::default())),
54 Self::Google => Ok((
55 ResolverConfig::udp_and_tcp(&GOOGLE),
56 ResolverOpts::default(),
57 )),
58 Self::GoogleTls => Ok((ResolverConfig::tls(&GOOGLE), ResolverOpts::default())),
59 Self::Quad9 => Ok((ResolverConfig::udp_and_tcp(&QUAD9), ResolverOpts::default())),
60 Self::Quad9Tls => Ok((ResolverConfig::tls(&QUAD9), ResolverOpts::default())),
61 }
62 }
63}
64
65use crate::dane::TlsaRecord;
66use crate::error::{DaneError, DnsError};
67use ans_types::{Fqdn, ParseError, Version};
68
69#[derive(Debug, Clone)]
74pub struct BadgeRecord {
75 pub(crate) format_version: String,
77 pub(crate) version: Option<Version>,
79 pub(crate) url: String,
81}
82
83impl BadgeRecord {
84 pub fn format_version(&self) -> &str {
86 &self.format_version
87 }
88
89 pub fn version(&self) -> Option<&Version> {
91 self.version.as_ref()
92 }
93
94 pub fn url(&self) -> &str {
96 &self.url
97 }
98
99 pub fn parse(txt: &str) -> Result<Self, ParseError> {
108 let mut format_version = None;
109 let mut version = None;
110 let mut url = None;
111
112 for part in txt.split(';') {
113 let part = part.trim();
114 if let Some(v) = part.strip_prefix("v=") {
115 format_version = Some(v.to_string());
116 } else if let Some(v) = part.strip_prefix("version=") {
117 version = Version::parse(v).ok();
118 } else if let Some(u) = part.strip_prefix("url=") {
119 url::Url::parse(u).map_err(|e| ParseError::InvalidUrl(e.to_string()))?;
121 url = Some(u.to_string());
122 }
123 }
124
125 let format_version =
126 format_version.ok_or_else(|| ParseError::MissingField("v".to_string()))?;
127 let url = url.ok_or_else(|| ParseError::MissingField("url".to_string()))?;
128
129 tracing::debug!(
130 format_version = %format_version,
131 version = ?version,
132 url = %url,
133 "Parsed badge TXT record"
134 );
135
136 Ok(Self {
137 format_version,
138 version,
139 url,
140 })
141 }
142}
143
144#[cfg(any(test, feature = "test-support"))]
145impl BadgeRecord {
146 pub fn new(
150 format_version: impl Into<String>,
151 version: Option<Version>,
152 url: impl Into<String>,
153 ) -> Self {
154 Self {
155 format_version: format_version.into(),
156 version,
157 url: url.into(),
158 }
159 }
160}
161
162#[derive(Debug, Clone)]
164#[non_exhaustive]
165pub enum DnsLookupResult<T> {
166 Found(Vec<T>),
168 NotFound,
170}
171
172#[async_trait]
177pub trait DnsResolver: Send + Sync {
178 async fn lookup_badge(&self, fqdn: &Fqdn) -> Result<DnsLookupResult<BadgeRecord>, DnsError>;
182
183 async fn lookup_tlsa(
188 &self,
189 fqdn: &Fqdn,
190 port: u16,
191 ) -> Result<DnsLookupResult<TlsaRecord>, DnsError>;
192
193 async fn get_badge_records(&self, fqdn: &Fqdn) -> Result<Vec<BadgeRecord>, DnsError> {
196 match self.lookup_badge(fqdn).await? {
197 DnsLookupResult::Found(records) => Ok(records),
198 DnsLookupResult::NotFound => Err(DnsError::NotFound {
199 fqdn: fqdn.to_string(),
200 }),
201 }
202 }
203
204 async fn get_tlsa_records(&self, fqdn: &Fqdn, port: u16) -> Result<Vec<TlsaRecord>, DaneError> {
206 match self.lookup_tlsa(fqdn, port).await {
207 Ok(DnsLookupResult::Found(records)) => Ok(records),
208 Ok(DnsLookupResult::NotFound) => Ok(vec![]),
209 Err(e) => Err(DaneError::DnsError(e)),
210 }
211 }
212
213 async fn find_badge_for_version(
215 &self,
216 fqdn: &Fqdn,
217 version: &Version,
218 ) -> Result<Option<BadgeRecord>, DnsError> {
219 let records = self.get_badge_records(fqdn).await?;
220 Ok(records.into_iter().find(|r| {
222 match &r.version {
223 Some(v) => v == version,
224 None => true, }
226 }))
227 }
228
229 async fn find_preferred_badge(&self, fqdn: &Fqdn) -> Result<Option<BadgeRecord>, DnsError> {
232 let mut records = self.get_badge_records(fqdn).await?;
233
234 if records.is_empty() {
235 return Ok(None);
236 }
237
238 records.sort_by(|a, b| match (&b.version, &a.version) {
240 (Some(vb), Some(va)) => vb.cmp(va),
241 (Some(_), None) => std::cmp::Ordering::Less,
242 (None, Some(_)) => std::cmp::Ordering::Greater,
243 (None, None) => std::cmp::Ordering::Equal,
244 });
245
246 Ok(Some(records.remove(0)))
247 }
248}
249
250pub struct HickoryDnsResolver {
252 resolver: TokioResolver,
253 dnssec_resolver: TokioResolver,
255}
256
257impl fmt::Debug for HickoryDnsResolver {
258 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259 f.debug_struct("HickoryDnsResolver").finish_non_exhaustive()
260 }
261}
262
263#[allow(clippy::unused_async)] impl HickoryDnsResolver {
265 pub async fn new() -> Result<Self, DnsError> {
270 Self::with_preset(DnsResolverConfig::System).await
271 }
272
273 pub async fn with_preset(preset: DnsResolverConfig) -> Result<Self, DnsError> {
278 let (config, opts) = preset.to_resolver_config()?;
279
280 let mut builder =
281 TokioResolver::builder_with_config(config.clone(), TokioRuntimeProvider::default());
282 *builder.options_mut() = opts.clone();
283 let resolver = builder.build().map_err(|e| DnsError::LookupFailed {
284 fqdn: "(resolver init)".to_string(),
285 reason: e.to_string(),
286 })?;
287
288 let mut dnssec_builder =
290 TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
291 let dnssec_opts = dnssec_builder.options_mut();
292 *dnssec_opts = opts;
293 dnssec_opts.validate = true;
294 let dnssec_resolver = dnssec_builder.build().map_err(|e| DnsError::LookupFailed {
295 fqdn: "(dnssec resolver init)".to_string(),
296 reason: e.to_string(),
297 })?;
298
299 tracing::debug!(preset = ?preset, "Created DNS resolver");
300 Ok(Self {
301 resolver,
302 dnssec_resolver,
303 })
304 }
305
306 pub async fn with_nameservers(nameservers: &[Ipv4Addr]) -> Result<Self, DnsError> {
323 let ips: Vec<IpAddr> = nameservers.iter().map(|ip| IpAddr::V4(*ip)).collect();
324
325 let ns_configs: Vec<NameServerConfig> = ips
326 .iter()
327 .map(|ip| NameServerConfig::udp_and_tcp(*ip))
328 .collect();
329 let config = ResolverConfig::from_parts(None, vec![], ns_configs);
330
331 let resolver =
332 TokioResolver::builder_with_config(config.clone(), TokioRuntimeProvider::default())
333 .build()
334 .map_err(|e| DnsError::LookupFailed {
335 fqdn: "(resolver init)".to_string(),
336 reason: e.to_string(),
337 })?;
338
339 let mut dnssec_builder =
341 TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
342 dnssec_builder.options_mut().validate = true;
343 let dnssec_resolver = dnssec_builder.build().map_err(|e| DnsError::LookupFailed {
344 fqdn: "(dnssec resolver init)".to_string(),
345 reason: e.to_string(),
346 })?;
347
348 tracing::debug!(nameservers = ?nameservers, "Created DNS resolver with custom nameservers");
349 Ok(Self {
350 resolver,
351 dnssec_resolver,
352 })
353 }
354
355 pub async fn with_config(config: ResolverConfig, opts: ResolverOpts) -> Result<Self, DnsError> {
357 let mut builder =
358 TokioResolver::builder_with_config(config.clone(), TokioRuntimeProvider::default());
359 *builder.options_mut() = opts.clone();
360 let resolver = builder.build().map_err(|e| DnsError::LookupFailed {
361 fqdn: "(resolver init)".to_string(),
362 reason: e.to_string(),
363 })?;
364
365 let mut dnssec_builder =
367 TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
368 let dnssec_opts = dnssec_builder.options_mut();
369 *dnssec_opts = opts;
370 dnssec_opts.validate = true;
371 let dnssec_resolver = dnssec_builder.build().map_err(|e| DnsError::LookupFailed {
372 fqdn: "(dnssec resolver init)".to_string(),
373 reason: e.to_string(),
374 })?;
375
376 Ok(Self {
377 resolver,
378 dnssec_resolver,
379 })
380 }
381
382 pub async fn with_dnssec() -> Result<Self, DnsError> {
384 let mut builder = TokioResolver::builder_with_config(
385 ResolverConfig::default(),
386 TokioRuntimeProvider::default(),
387 );
388 builder.options_mut().validate = true;
389 let resolver = builder.build().map_err(|e| DnsError::LookupFailed {
390 fqdn: "(resolver init)".to_string(),
391 reason: e.to_string(),
392 })?;
393
394 let mut dnssec_builder = TokioResolver::builder_with_config(
395 ResolverConfig::default(),
396 TokioRuntimeProvider::default(),
397 );
398 dnssec_builder.options_mut().validate = true;
399 let dnssec_resolver = dnssec_builder.build().map_err(|e| DnsError::LookupFailed {
400 fqdn: "(dnssec resolver init)".to_string(),
401 reason: e.to_string(),
402 })?;
403
404 Ok(Self {
405 resolver,
406 dnssec_resolver,
407 })
408 }
409}
410
411impl HickoryDnsResolver {
412 async fn query_badge_txt(
414 &self,
415 query_name: &str,
416 fqdn: &Fqdn,
417 ) -> Result<DnsLookupResult<BadgeRecord>, DnsError> {
418 let response = match self.resolver.txt_lookup(query_name).await {
419 Ok(response) => response,
420 Err(NetError::Dns(HickoryDnsError::NoRecordsFound(_))) => {
421 return Ok(DnsLookupResult::NotFound);
422 }
423 Err(NetError::Timeout) => {
424 return Err(DnsError::Timeout {
425 fqdn: fqdn.to_string(),
426 });
427 }
428 Err(e) => {
429 return Err(DnsError::LookupFailed {
430 fqdn: fqdn.to_string(),
431 reason: e.to_string(),
432 });
433 }
434 };
435
436 let mut records = Vec::new();
437 for record in response.answers() {
438 let RData::TXT(txt) = &record.data else {
439 continue;
440 };
441 let txt_data: String = txt
442 .txt_data
443 .iter()
444 .map(|d| String::from_utf8_lossy(d).to_string())
445 .collect::<String>();
446
447 match BadgeRecord::parse(&txt_data) {
448 Ok(badge) => records.push(badge),
449 Err(_) => {
450 tracing::warn!(
451 fqdn = %fqdn,
452 record = %txt_data,
453 "Skipping malformed badge TXT record"
454 );
455 }
456 }
457 }
458
459 if records.is_empty() {
460 Ok(DnsLookupResult::NotFound)
461 } else {
462 Ok(DnsLookupResult::Found(records))
463 }
464 }
465}
466
467#[allow(clippy::too_many_lines)]
468#[async_trait]
469impl DnsResolver for HickoryDnsResolver {
470 async fn lookup_badge(&self, fqdn: &Fqdn) -> Result<DnsLookupResult<BadgeRecord>, DnsError> {
471 let primary = fqdn.ans_badge_name();
473 tracing::debug!(query = %primary, "Querying primary _ans-badge record");
474 match self.query_badge_txt(&primary, fqdn).await? {
475 DnsLookupResult::Found(records) => return Ok(DnsLookupResult::Found(records)),
476 DnsLookupResult::NotFound => {
477 let fallback = fqdn.ra_badge_name();
479 tracing::debug!(query = %fallback, "Primary not found, falling back to _ra-badge");
480 self.query_badge_txt(&fallback, fqdn).await
481 }
482 }
483 }
484
485 async fn lookup_tlsa(
486 &self,
487 fqdn: &Fqdn,
488 port: u16,
489 ) -> Result<DnsLookupResult<TlsaRecord>, DnsError> {
490 let query_name = fqdn.tlsa_name(port);
491
492 tracing::debug!(
495 query = %query_name,
496 "Performing DNSSEC-validated TLSA lookup"
497 );
498
499 let response = match self.dnssec_resolver.tlsa_lookup(&query_name).await {
500 Ok(response) => response,
501 Err(NetError::Dns(HickoryDnsError::NoRecordsFound(HickoryNoRecords {
502 response_code,
503 ..
504 }))) => {
505 if response_code == ResponseCode::ServFail {
509 tracing::error!(
510 fqdn = %fqdn,
511 "TLSA lookup returned ServFail — possible DNSSEC failure"
512 );
513 return Err(DnsError::DnssecFailed {
514 fqdn: fqdn.to_string(),
515 });
516 }
517 return Ok(DnsLookupResult::NotFound);
518 }
519 Err(NetError::Timeout) => {
520 return Err(DnsError::Timeout {
521 fqdn: fqdn.to_string(),
522 });
523 }
524 Err(NetError::Dns(HickoryDnsError::Nsec { .. })) => {
526 tracing::error!(
527 fqdn = %fqdn,
528 "DNSSEC validation failed for TLSA record (NSEC proof)"
529 );
530 return Err(DnsError::DnssecFailed {
531 fqdn: fqdn.to_string(),
532 });
533 }
534 Err(e) => {
535 let err_str = e.to_string();
537 if matches_dnssec_pattern(&err_str) {
538 tracing::error!(
539 fqdn = %fqdn,
540 error = %e,
541 "DNSSEC validation failed for TLSA record"
542 );
543 return Err(DnsError::DnssecFailed {
544 fqdn: fqdn.to_string(),
545 });
546 }
547 return Err(DnsError::LookupFailed {
548 fqdn: fqdn.to_string(),
549 reason: e.to_string(),
550 });
551 }
552 };
553
554 tracing::debug!(
561 fqdn = %fqdn,
562 port,
563 "TLSA lookup succeeded (DNSSEC validated if domain has DNSSEC)"
564 );
565
566 let mut records = Vec::new();
567 for record in response.answers() {
568 let RData::TLSA(tlsa) = &record.data else {
569 continue;
570 };
571 let mut rdata = vec![
573 u8::from(tlsa.cert_usage),
574 u8::from(tlsa.selector),
575 u8::from(tlsa.matching),
576 ];
577 rdata.extend(&tlsa.cert_data);
578
579 match TlsaRecord::from_rdata(&rdata) {
580 Ok(record) => {
581 tracing::debug!(
582 fqdn = %fqdn,
583 port,
584 usage = ?record.usage,
585 selector = ?record.selector,
586 matching_type = ?record.matching_type,
587 "Parsed TLSA record"
588 );
589 records.push(record);
590 }
591 Err(e) => {
592 tracing::warn!(
593 fqdn = %fqdn,
594 port,
595 error = %e,
596 "Skipping malformed TLSA record"
597 );
598 }
599 }
600 }
601
602 if records.is_empty() {
603 Ok(DnsLookupResult::NotFound)
604 } else {
605 Ok(DnsLookupResult::Found(records))
606 }
607 }
608}
609
610fn matches_dnssec_pattern(err_str: &str) -> bool {
614 err_str.contains("DNSSEC") || err_str.contains("validation")
615}
616
617#[cfg(any(test, feature = "test-support"))]
619#[derive(Debug, Default, Clone)]
620pub struct MockDnsResolver {
621 records: std::collections::HashMap<String, Vec<BadgeRecord>>,
622 tlsa_records: std::collections::HashMap<String, Vec<TlsaRecord>>,
623 errors: std::collections::HashMap<String, DnsError>,
624 tlsa_errors: std::collections::HashMap<String, DnsError>,
625}
626
627#[cfg(any(test, feature = "test-support"))]
628impl MockDnsResolver {
629 pub fn new() -> Self {
631 Self::default()
632 }
633
634 pub fn with_records(mut self, fqdn: &str, records: Vec<BadgeRecord>) -> Self {
636 self.records.insert(fqdn.to_lowercase(), records);
637 self
638 }
639
640 pub fn with_tlsa_records(mut self, fqdn: &str, port: u16, records: Vec<TlsaRecord>) -> Self {
642 let key = format!("{}:{}", fqdn.to_lowercase(), port);
643 self.tlsa_records.insert(key, records);
644 self
645 }
646
647 pub fn with_error(mut self, fqdn: &str, error: DnsError) -> Self {
649 self.errors.insert(fqdn.to_lowercase(), error);
650 self
651 }
652
653 pub fn with_tlsa_error(mut self, fqdn: &str, port: u16, error: DnsError) -> Self {
659 let key = format!("{}:{}", fqdn.to_lowercase(), port);
660 self.tlsa_errors.insert(key, error);
661 self
662 }
663}
664
665#[cfg(any(test, feature = "test-support"))]
666#[async_trait]
667impl DnsResolver for MockDnsResolver {
668 async fn lookup_badge(&self, fqdn: &Fqdn) -> Result<DnsLookupResult<BadgeRecord>, DnsError> {
669 let key = fqdn.as_str().to_lowercase();
670
671 if let Some(error) = self.errors.get(&key) {
673 return Err(error.clone());
674 }
675
676 match self.records.get(&key) {
678 Some(records) if !records.is_empty() => Ok(DnsLookupResult::Found(records.clone())),
679 _ => Ok(DnsLookupResult::NotFound),
680 }
681 }
682
683 async fn lookup_tlsa(
684 &self,
685 fqdn: &Fqdn,
686 port: u16,
687 ) -> Result<DnsLookupResult<TlsaRecord>, DnsError> {
688 let key = format!("{}:{}", fqdn.as_str().to_lowercase(), port);
689
690 if let Some(error) = self.tlsa_errors.get(&key) {
692 return Err(error.clone());
693 }
694
695 if let Some(error) = self.errors.get(&fqdn.as_str().to_lowercase()) {
697 return Err(error.clone());
698 }
699
700 match self.tlsa_records.get(&key) {
702 Some(records) if !records.is_empty() => Ok(DnsLookupResult::Found(records.clone())),
703 _ => Ok(DnsLookupResult::NotFound),
704 }
705 }
706}
707
708#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
709#[cfg(test)]
710mod tests {
711 use super::*;
712
713 #[test]
714 fn test_parse_badge_record_with_version() {
715 let txt = "v=ans-badge1; version=v1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/7b93c61c-e261-488c-89a3-f948119be0a0";
716 let record = BadgeRecord::parse(txt).unwrap();
717
718 assert_eq!(record.format_version, "ans-badge1");
719 assert_eq!(record.version, Some(Version::new(1, 0, 0)));
720 assert_eq!(
721 record.url,
722 "https://transparency.ans.godaddy.com/v1/agents/7b93c61c-e261-488c-89a3-f948119be0a0"
723 );
724 }
725
726 #[test]
727 fn test_parse_badge_record_without_version() {
728 let txt = "v=ans-badge1; url=https://transparency.ans.ote-godaddy.com/v1/agents/835a27a8-6b20-4439-915e-668a9d36e469";
729 let record = BadgeRecord::parse(txt).unwrap();
730
731 assert_eq!(record.format_version, "ans-badge1");
732 assert_eq!(record.version, None);
733 assert_eq!(
734 record.url,
735 "https://transparency.ans.ote-godaddy.com/v1/agents/835a27a8-6b20-4439-915e-668a9d36e469"
736 );
737 }
738
739 #[test]
740 fn test_parse_badge_record_missing_url() {
741 let txt = "v=ans-badge1; version=v1.0.0";
742 assert!(BadgeRecord::parse(txt).is_err());
743 }
744
745 #[test]
746 fn test_parse_badge_record_no_space_after_semicolon() {
747 let txt = "v=ans-badge1;version=v1.0.0;url=https://transparency.ans.godaddy.com/v1/agents/7b93c61c-e261-488c-89a3-f948119be0a0";
748 let record = BadgeRecord::parse(txt).unwrap();
749
750 assert_eq!(record.format_version, "ans-badge1");
751 assert_eq!(record.version, Some(Version::new(1, 0, 0)));
752 assert_eq!(
753 record.url,
754 "https://transparency.ans.godaddy.com/v1/agents/7b93c61c-e261-488c-89a3-f948119be0a0"
755 );
756 }
757
758 #[test]
759 fn test_parse_legacy_ra_badge_format() {
760 let txt = "v=ra-badge1; version=1.0.0; url=https://transparency.ans.godaddy.com/v1/agents/test-id";
761 let record = BadgeRecord::parse(txt).unwrap();
762
763 assert_eq!(record.format_version, "ra-badge1");
764 assert_eq!(record.version, Some(Version::new(1, 0, 0)));
765 }
766
767 #[tokio::test]
768 async fn test_mock_resolver_found() {
769 let record = BadgeRecord {
770 format_version: "ans-badge1".to_string(),
771 version: Some(Version::new(1, 0, 0)),
772 url: "https://example.com/badge".to_string(),
773 };
774
775 let resolver =
776 MockDnsResolver::new().with_records("agent.example.com", vec![record.clone()]);
777
778 let fqdn = Fqdn::new("agent.example.com").unwrap();
779 let result = resolver.lookup_badge(&fqdn).await.unwrap();
780
781 match result {
782 DnsLookupResult::Found(records) => {
783 assert_eq!(records.len(), 1);
784 assert_eq!(records[0].version, Some(Version::new(1, 0, 0)));
785 }
786 DnsLookupResult::NotFound => panic!("Expected Found"),
787 }
788 }
789
790 #[tokio::test]
791 async fn test_mock_resolver_not_found() {
792 let resolver = MockDnsResolver::new();
793 let fqdn = Fqdn::new("unknown.example.com").unwrap();
794 let result = resolver.lookup_badge(&fqdn).await.unwrap();
795
796 assert!(matches!(result, DnsLookupResult::NotFound));
797 }
798
799 #[tokio::test]
800 async fn test_mock_resolver_error() {
801 let resolver = MockDnsResolver::new().with_error(
802 "error.example.com",
803 DnsError::Timeout {
804 fqdn: "error.example.com".to_string(),
805 },
806 );
807
808 let fqdn = Fqdn::new("error.example.com").unwrap();
809 let result = resolver.lookup_badge(&fqdn).await;
810
811 assert!(matches!(result, Err(DnsError::Timeout { .. })));
812 }
813
814 #[tokio::test]
815 async fn test_find_badge_for_version() {
816 let v1 = BadgeRecord {
817 format_version: "ans-badge1".to_string(),
818 version: Some(Version::new(1, 0, 0)),
819 url: "https://example.com/v1".to_string(),
820 };
821 let v2 = BadgeRecord {
822 format_version: "ans-badge1".to_string(),
823 version: Some(Version::new(1, 0, 1)),
824 url: "https://example.com/v2".to_string(),
825 };
826
827 let resolver = MockDnsResolver::new().with_records("agent.example.com", vec![v1, v2]);
828
829 let fqdn = Fqdn::new("agent.example.com").unwrap();
830
831 let found = resolver
833 .find_badge_for_version(&fqdn, &Version::new(1, 0, 0))
834 .await
835 .unwrap();
836 assert!(found.is_some());
837 assert_eq!(found.unwrap().version, Some(Version::new(1, 0, 0)));
838
839 let found = resolver
841 .find_badge_for_version(&fqdn, &Version::new(1, 0, 1))
842 .await
843 .unwrap();
844 assert!(found.is_some());
845 assert_eq!(found.unwrap().version, Some(Version::new(1, 0, 1)));
846
847 let found = resolver
849 .find_badge_for_version(&fqdn, &Version::new(2, 0, 0))
850 .await
851 .unwrap();
852 assert!(found.is_none());
853 }
854
855 #[test]
864 fn test_dnssec_pattern_matches_uppercase_dnssec() {
865 assert!(matches_dnssec_pattern("DNSSEC validation failed"));
866 assert!(matches_dnssec_pattern("DNSSEC error: bogus response"));
867 assert!(matches_dnssec_pattern("proto error: DNSSEC"));
868 }
869
870 #[test]
871 fn test_dnssec_pattern_matches_validation_keyword() {
872 assert!(matches_dnssec_pattern("validation failed for record"));
873 assert!(matches_dnssec_pattern("RRSIG validation error"));
874 assert!(matches_dnssec_pattern("chain of trust validation failure"));
875 }
876
877 #[test]
878 fn test_dnssec_pattern_known_hickory_messages() {
879 let known_messages = [
883 "DNSSEC validation failed",
884 "DNSSEC error",
885 "validation of DNSKEY failed",
886 "no DNSKEY proof for DS: validation failed",
887 ];
888 for msg in &known_messages {
889 assert!(
890 matches_dnssec_pattern(msg),
891 "Expected DNSSEC pattern match for known hickory message: {msg:?}"
892 );
893 }
894 }
895
896 #[test]
897 fn test_dnssec_pattern_does_not_match_generic_errors() {
898 assert!(!matches_dnssec_pattern("connection refused"));
900 assert!(!matches_dnssec_pattern("timeout"));
901 assert!(!matches_dnssec_pattern("no records found"));
902 assert!(!matches_dnssec_pattern("io error: broken pipe"));
903 assert!(!matches_dnssec_pattern("proto error: invalid message"));
904 }
905
906 #[tokio::test]
907 async fn test_mock_tlsa_dnssec_error() {
908 let resolver = MockDnsResolver::new().with_tlsa_error(
909 "secure.example.com",
910 443,
911 DnsError::DnssecFailed {
912 fqdn: "secure.example.com".to_string(),
913 },
914 );
915
916 let fqdn = Fqdn::new("secure.example.com").unwrap();
917 let result = resolver.lookup_tlsa(&fqdn, 443).await;
918 assert!(matches!(result, Err(DnsError::DnssecFailed { .. })));
919 }
920
921 #[tokio::test]
931 #[ignore = "requires network access — run with: cargo test -p ans-verify -- --ignored"]
932 async fn test_real_dnssec_chain_validation_fails() {
933 use hickory_resolver::TokioResolver;
934 use hickory_resolver::config::LookupIpStrategy;
935 use hickory_resolver::net::{
936 DnsError as HickoryDnsError, NetError, NoRecords as HickoryNoRecords,
937 };
938
939 let mut builder = TokioResolver::builder_with_config(
940 hickory_resolver::config::ResolverConfig::default(),
941 TokioRuntimeProvider::default(),
942 );
943 let opts = builder.options_mut();
944 opts.validate = true;
945 opts.ip_strategy = LookupIpStrategy::Ipv4Only;
946 let resolver = builder.build().expect("resolver build must succeed");
947
948 let result = resolver.lookup_ip("dnssec-failed.org.").await;
949 let err = result.expect_err("dnssec-failed.org must not resolve — DNSSEC chain is broken");
950
951 match err {
952 NetError::Dns(HickoryDnsError::NoRecordsFound(HickoryNoRecords {
954 response_code: ResponseCode::ServFail,
955 ..
956 })) => {}
957
958 NetError::Dns(HickoryDnsError::Nsec { .. }) => {}
960
961 other => {
963 let err_str = other.to_string();
964 assert!(
965 matches_dnssec_pattern(&err_str),
966 "Error from dnssec-failed.org did not match DNSSEC detection patterns. \
967 Hickory may have changed error format. Error: {err_str}"
968 );
969 }
970 }
971 }
972
973 #[tokio::test]
974 async fn test_mock_tlsa_error_independent_of_badge() {
975 let record = BadgeRecord {
977 format_version: "ans-badge1".to_string(),
978 version: Some(Version::new(1, 0, 0)),
979 url: "https://example.com/badge".to_string(),
980 };
981
982 let resolver = MockDnsResolver::new()
983 .with_records("agent.example.com", vec![record])
984 .with_tlsa_error(
985 "agent.example.com",
986 443,
987 DnsError::DnssecFailed {
988 fqdn: "agent.example.com".to_string(),
989 },
990 );
991
992 let fqdn = Fqdn::new("agent.example.com").unwrap();
993
994 let badge_result = resolver.lookup_badge(&fqdn).await;
996 assert!(badge_result.is_ok());
997
998 let tlsa_result = resolver.lookup_tlsa(&fqdn, 443).await;
1000 assert!(matches!(tlsa_result, Err(DnsError::DnssecFailed { .. })));
1001 }
1002
1003 #[test]
1006 fn test_dns_resolver_config_default() {
1007 assert_eq!(DnsResolverConfig::default(), DnsResolverConfig::System);
1008 }
1009
1010 #[test]
1011 fn test_cloudflare_preset() {
1012 let (config, _) = DnsResolverConfig::Cloudflare.to_resolver_config().unwrap();
1013 assert!(!config.name_servers().is_empty());
1014 }
1015
1016 #[test]
1017 fn test_cloudflare_tls_preset() {
1018 let (config, _) = DnsResolverConfig::CloudflareTls
1019 .to_resolver_config()
1020 .unwrap();
1021 assert!(!config.name_servers().is_empty());
1022 }
1023
1024 #[test]
1025 fn test_google_preset() {
1026 let (config, _) = DnsResolverConfig::Google.to_resolver_config().unwrap();
1027 assert!(!config.name_servers().is_empty());
1028 }
1029
1030 #[test]
1031 fn test_google_tls_preset() {
1032 let (config, _) = DnsResolverConfig::GoogleTls.to_resolver_config().unwrap();
1033 assert!(!config.name_servers().is_empty());
1034 }
1035
1036 #[test]
1037 fn test_quad9_preset() {
1038 let (config, _) = DnsResolverConfig::Quad9.to_resolver_config().unwrap();
1039 assert!(!config.name_servers().is_empty());
1040 }
1041
1042 #[test]
1043 fn test_quad9_tls_preset() {
1044 let (config, _) = DnsResolverConfig::Quad9Tls.to_resolver_config().unwrap();
1045 assert!(!config.name_servers().is_empty());
1046 }
1047
1048 #[tokio::test]
1051 async fn test_hickory_with_preset_cloudflare() {
1052 let resolver = HickoryDnsResolver::with_preset(DnsResolverConfig::Cloudflare).await;
1053 assert!(resolver.is_ok());
1054 }
1055
1056 #[tokio::test]
1057 async fn test_hickory_with_preset_google() {
1058 let resolver = HickoryDnsResolver::with_preset(DnsResolverConfig::Google).await;
1059 assert!(resolver.is_ok());
1060 }
1061
1062 #[tokio::test]
1063 async fn test_hickory_with_preset_quad9() {
1064 let resolver = HickoryDnsResolver::with_preset(DnsResolverConfig::Quad9).await;
1065 assert!(resolver.is_ok());
1066 }
1067
1068 #[tokio::test]
1069 async fn test_hickory_with_nameservers() {
1070 let resolver = HickoryDnsResolver::with_nameservers(&[
1071 Ipv4Addr::new(1, 1, 1, 1),
1072 Ipv4Addr::new(8, 8, 8, 8),
1073 ])
1074 .await;
1075 assert!(resolver.is_ok());
1076 }
1077
1078 #[tokio::test]
1079 async fn test_hickory_with_config() {
1080 let resolver = HickoryDnsResolver::with_config(
1081 ResolverConfig::udp_and_tcp(&CLOUDFLARE),
1082 ResolverOpts::default(),
1083 )
1084 .await;
1085 assert!(resolver.is_ok());
1086 }
1087
1088 #[tokio::test]
1089 async fn test_hickory_with_dnssec() {
1090 let resolver = HickoryDnsResolver::with_dnssec().await;
1091 assert!(resolver.is_ok());
1092 }
1093
1094 #[tokio::test]
1095 async fn test_hickory_debug_format() {
1096 let resolver = HickoryDnsResolver::with_preset(DnsResolverConfig::Cloudflare)
1097 .await
1098 .unwrap();
1099 let dbg = format!("{resolver:?}");
1100 assert!(dbg.contains("HickoryDnsResolver"));
1101 }
1102
1103 #[tokio::test]
1106 async fn test_get_badge_records_found() {
1107 let record = BadgeRecord::new(
1108 "ans-badge1",
1109 Some(Version::new(1, 0, 0)),
1110 "https://example.com/badge",
1111 );
1112 let resolver = MockDnsResolver::new().with_records("agent.example.com", vec![record]);
1113 let fqdn = Fqdn::new("agent.example.com").unwrap();
1114
1115 let records = resolver.get_badge_records(&fqdn).await.unwrap();
1116 assert_eq!(records.len(), 1);
1117 }
1118
1119 #[tokio::test]
1120 async fn test_get_badge_records_not_found() {
1121 let resolver = MockDnsResolver::new();
1122 let fqdn = Fqdn::new("unknown.example.com").unwrap();
1123
1124 let result = resolver.get_badge_records(&fqdn).await;
1125 assert!(matches!(result, Err(DnsError::NotFound { .. })));
1126 }
1127
1128 #[tokio::test]
1129 async fn test_get_tlsa_records_found() {
1130 let tlsa = crate::dane::TlsaRecord::new(
1131 crate::dane::TlsaUsage::DomainIssuedCertificate,
1132 crate::dane::TlsaSelector::FullCertificate,
1133 crate::dane::TlsaMatchingType::Sha256,
1134 vec![0; 32],
1135 );
1136 let resolver =
1137 MockDnsResolver::new().with_tlsa_records("agent.example.com", 443, vec![tlsa]);
1138 let fqdn = Fqdn::new("agent.example.com").unwrap();
1139
1140 let records = resolver.get_tlsa_records(&fqdn, 443).await.unwrap();
1141 assert_eq!(records.len(), 1);
1142 }
1143
1144 #[tokio::test]
1145 async fn test_get_tlsa_records_not_found() {
1146 let resolver = MockDnsResolver::new();
1147 let fqdn = Fqdn::new("unknown.example.com").unwrap();
1148
1149 let records = resolver.get_tlsa_records(&fqdn, 443).await.unwrap();
1150 assert!(records.is_empty());
1151 }
1152
1153 #[tokio::test]
1154 async fn test_get_tlsa_records_error_propagation() {
1155 let resolver = MockDnsResolver::new().with_tlsa_error(
1156 "agent.example.com",
1157 443,
1158 DnsError::DnssecFailed {
1159 fqdn: "agent.example.com".to_string(),
1160 },
1161 );
1162 let fqdn = Fqdn::new("agent.example.com").unwrap();
1163
1164 let result = resolver.get_tlsa_records(&fqdn, 443).await;
1165 assert!(result.is_err());
1166 }
1167
1168 #[tokio::test]
1169 async fn test_find_preferred_badge_newest_first() {
1170 let v1 = BadgeRecord::new(
1171 "ans-badge1",
1172 Some(Version::new(1, 0, 0)),
1173 "https://example.com/v1",
1174 );
1175 let v2 = BadgeRecord::new(
1176 "ans-badge1",
1177 Some(Version::new(2, 0, 0)),
1178 "https://example.com/v2",
1179 );
1180 let resolver = MockDnsResolver::new().with_records("agent.example.com", vec![v1, v2]);
1181 let fqdn = Fqdn::new("agent.example.com").unwrap();
1182
1183 let preferred = resolver.find_preferred_badge(&fqdn).await.unwrap().unwrap();
1184 assert_eq!(preferred.version(), Some(&Version::new(2, 0, 0)));
1185 }
1186
1187 #[tokio::test]
1188 async fn test_find_preferred_badge_none_version_sorting() {
1189 let versioned = BadgeRecord::new(
1192 "ans-badge1",
1193 Some(Version::new(1, 0, 0)),
1194 "https://example.com/v1",
1195 );
1196 let unversioned = BadgeRecord::new("ans-badge1", None, "https://example.com/unversioned");
1197 let resolver =
1198 MockDnsResolver::new().with_records("agent.example.com", vec![versioned, unversioned]);
1199 let fqdn = Fqdn::new("agent.example.com").unwrap();
1200
1201 let preferred = resolver.find_preferred_badge(&fqdn).await.unwrap().unwrap();
1202 assert_eq!(preferred.version(), None);
1204 }
1205
1206 #[tokio::test]
1207 async fn test_find_preferred_badge_empty() {
1208 let resolver = MockDnsResolver::new();
1209 let fqdn = Fqdn::new("unknown.example.com").unwrap();
1210
1211 let result = resolver.find_preferred_badge(&fqdn).await;
1213 assert!(result.is_err());
1214 }
1215
1216 #[tokio::test]
1217 async fn test_find_badge_for_version_none_matches_any() {
1218 let unversioned = BadgeRecord::new("ans-badge1", None, "https://example.com/badge");
1219 let resolver = MockDnsResolver::new().with_records("agent.example.com", vec![unversioned]);
1220 let fqdn = Fqdn::new("agent.example.com").unwrap();
1221
1222 let found = resolver
1223 .find_badge_for_version(&fqdn, &Version::new(99, 0, 0))
1224 .await
1225 .unwrap();
1226 assert!(found.is_some());
1227 }
1228
1229 #[test]
1232 fn test_badge_record_accessors() {
1233 let record = BadgeRecord::new(
1234 "ans-badge1",
1235 Some(Version::new(1, 2, 3)),
1236 "https://example.com/badge",
1237 );
1238 assert_eq!(record.format_version(), "ans-badge1");
1239 assert_eq!(record.version(), Some(&Version::new(1, 2, 3)));
1240 assert_eq!(record.url(), "https://example.com/badge");
1241 }
1242
1243 #[test]
1244 fn test_badge_record_no_version() {
1245 let record = BadgeRecord::new("ra-badge1", None, "https://example.com/badge");
1246 assert_eq!(record.version(), None);
1247 }
1248}