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