Skip to main content

ans_verify/
dns.rs

1//! DNS resolution for `_ans-badge` / `_ra-badge` TXT records and TLSA records.
2
3use 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/// Well-known DNS resolver configurations.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16#[non_exhaustive]
17pub enum DnsResolverConfig {
18    /// System default resolver configuration.
19    #[default]
20    System,
21    /// Cloudflare DNS (1.1.1.1, 1.0.0.1).
22    Cloudflare,
23    /// Cloudflare DNS over TLS.
24    CloudflareTls,
25    /// Google Public DNS (8.8.8.8, 8.8.4.4).
26    Google,
27    /// Google DNS over TLS.
28    GoogleTls,
29    /// Quad9 DNS (9.9.9.9) - includes malware blocking.
30    Quad9,
31    /// Quad9 DNS over TLS.
32    Quad9Tls,
33}
34
35impl DnsResolverConfig {
36    /// Convert to hickory `ResolverConfig` and `ResolverOpts`.
37    ///
38    /// For `System`, reads the OS DNS configuration (e.g., `/etc/resolv.conf`
39    /// on Linux, `scutil --dns` on macOS). Other presets return hardcoded
40    /// public DNS configurations.
41    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/// Parsed badge TXT record from `_ans-badge` or `_ra-badge` DNS records.
70///
71/// In production, construct via [`BadgeRecord::parse`]. A `BadgeRecord::new`
72/// constructor is available only when the `test-support` feature is enabled.
73#[derive(Debug, Clone)]
74pub struct BadgeRecord {
75    /// Format version (e.g., "ans-badge1" or "ra-badge1").
76    pub(crate) format_version: String,
77    /// Agent version this badge represents (optional - may not be in DNS record).
78    pub(crate) version: Option<Version>,
79    /// URL to fetch the badge from the transparency log.
80    pub(crate) url: String,
81}
82
83impl BadgeRecord {
84    /// Returns the format version (e.g., "ans-badge1" or "ra-badge1").
85    pub fn format_version(&self) -> &str {
86        &self.format_version
87    }
88
89    /// Returns the agent version this badge represents, if specified.
90    pub fn version(&self) -> Option<&Version> {
91        self.version.as_ref()
92    }
93
94    /// Returns the URL to fetch the badge from the transparency log.
95    pub fn url(&self) -> &str {
96        &self.url
97    }
98
99    /// Parse from TXT record string.
100    ///
101    /// Accepts both new and legacy formats:
102    /// - `v=ans-badge1; version=v1.0.0; url=https://...`
103    /// - `v=ra-badge1; version=v1.0.0; url=https://...`
104    /// - `v=ans-badge1;version=v1.0.0;url=https://...` (no spaces)
105    ///
106    /// Version field is optional.
107    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                // Validate URL syntax but store as String to avoid exposing url::Url
120                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    /// Create a `BadgeRecord` for testing.
147    ///
148    /// In production, use [`BadgeRecord::parse`] to construct from DNS TXT record data.
149    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/// DNS lookup result distinguishing between "not found" and "error".
163#[derive(Debug, Clone)]
164#[non_exhaustive]
165pub enum DnsLookupResult<T> {
166    /// Records were found.
167    Found(Vec<T>),
168    /// Record does not exist (NXDOMAIN).
169    NotFound,
170}
171
172/// DNS resolver trait for looking up badge records and TLSA records.
173///
174/// Badge records are queried from `_ans-badge.{fqdn}` (primary) with
175/// fallback to `_ra-badge.{fqdn}` (legacy).
176#[async_trait]
177pub trait DnsResolver: Send + Sync {
178    /// Query badge TXT records for an FQDN.
179    ///
180    /// Implementations should query `_ans-badge` first, falling back to `_ra-badge`.
181    async fn lookup_badge(&self, fqdn: &Fqdn) -> Result<DnsLookupResult<BadgeRecord>, DnsError>;
182
183    /// Query TLSA records for an FQDN and port.
184    ///
185    /// Returns TLSA records from `_<port>._tcp.<fqdn>`.
186    /// Used for DANE verification of server certificates.
187    async fn lookup_tlsa(
188        &self,
189        fqdn: &Fqdn,
190        port: u16,
191    ) -> Result<DnsLookupResult<TlsaRecord>, DnsError>;
192
193    /// Query all badge records and return them.
194    /// Convenience method that unwraps the result.
195    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    /// Get TLSA records, returning empty vec if not found.
205    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    /// Find the badge record matching a specific version.
214    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        // Find record with matching version, or if version is None in record, it matches any
221        Ok(records.into_iter().find(|r| {
222            match &r.version {
223                Some(v) => v == version,
224                None => true, // Record without version can match any version
225            }
226        }))
227    }
228
229    /// Find the first ACTIVE badge (or any if none specified as active).
230    /// During version changes, prefer newer versions.
231    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        // Sort by version descending (newest first), None versions go last
239        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
250/// DNS resolver implementation using hickory-resolver.
251pub struct HickoryDnsResolver {
252    resolver: TokioResolver,
253    /// Separate resolver with DNSSEC validation for TLSA lookups.
254    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)] // kept async for API consistency; callers use .await
264impl HickoryDnsResolver {
265    /// Create a new resolver with system configuration.
266    ///
267    /// Regular queries use the default resolver.
268    /// TLSA queries use a DNSSEC-validating resolver for security.
269    pub async fn new() -> Result<Self, DnsError> {
270        Self::with_preset(DnsResolverConfig::System).await
271    }
272
273    /// Create a resolver with a preset configuration (Cloudflare, Google, etc.).
274    ///
275    /// For `System`, reads the OS DNS configuration. This uses the actual
276    /// nameservers configured on the machine (not hardcoded Google DNS).
277    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        // Create DNSSEC-validating resolver for TLSA lookups
289        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    /// Create a resolver with custom nameserver IP addresses.
307    ///
308    /// # Example
309    /// ```rust,no_run
310    /// use ans_verify::HickoryDnsResolver;
311    /// use std::net::Ipv4Addr;
312    ///
313    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
314    /// // Use custom nameservers
315    /// let resolver = HickoryDnsResolver::with_nameservers(&[
316    ///     Ipv4Addr::new(1, 1, 1, 1),
317    ///     Ipv4Addr::new(8, 8, 8, 8),
318    /// ]).await?;
319    /// # Ok(())
320    /// # }
321    /// ```
322    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        // Create DNSSEC-validating resolver for TLSA lookups
340        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    /// Create a new resolver with custom configuration.
356    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        // Create DNSSEC-validating resolver for TLSA lookups
366        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    /// Create a resolver with DNSSEC validation enabled for all queries.
383    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    /// Query badge TXT records at a specific DNS name.
413    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        // Try _ans-badge first (primary)
472        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                // Fall back to _ra-badge (legacy)
478                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        // Use DNSSEC-validating resolver for TLSA lookups
493        // This ensures TLSA records are protected by DNSSEC when available
494        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                // ServFail from a DNSSEC-validating resolver typically means
506                // the upstream rejected a bogus DNSSEC chain. Don't treat
507                // this as "not found" — surface it as a DNSSEC failure.
508                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            // DNSSEC negative proof — NSEC/NSEC3 authenticated denial of existence.
525            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                // Fallback: string match for untyped DNSSEC errors.
536                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        // Note: If we reach here, either:
555        // 1. DNSSEC validated successfully (secure)
556        // 2. Domain doesn't have DNSSEC (insecure but not bogus)
557        // Hickory doesn't easily expose which case we're in at the high-level API,
558        // so we log a general message. For domains without DNSSEC, the TLSA record
559        // provides no cryptographic binding guarantee.
560        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            // Build RDATA from TLSA record fields
572            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
610/// Returns true if the given error string contains patterns indicating a
611/// DNSSEC validation failure. Used as a fallback when hickory-resolver
612/// surfaces DNSSEC errors without a typed variant like `ProtoErrorKind::Nsec`.
613fn matches_dnssec_pattern(err_str: &str) -> bool {
614    err_str.contains("DNSSEC") || err_str.contains("validation")
615}
616
617/// Mock DNS resolver for testing.
618#[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    /// Create a new mock resolver.
630    pub fn new() -> Self {
631        Self::default()
632    }
633
634    /// Add badge records for an FQDN.
635    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    /// Add TLSA records for an FQDN and port.
641    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    /// Configure an error for an FQDN.
648    pub fn with_error(mut self, fqdn: &str, error: DnsError) -> Self {
649        self.errors.insert(fqdn.to_lowercase(), error);
650        self
651    }
652
653    /// Configure a TLSA-specific error for an FQDN and port.
654    ///
655    /// This allows TLSA lookups to fail independently of badge lookups.
656    /// Useful for testing DNSSEC validation failures on TLSA records
657    /// while badge DNS lookups succeed normally.
658    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        // Check for configured error first
672        if let Some(error) = self.errors.get(&key) {
673            return Err(error.clone());
674        }
675
676        // Return configured records or NotFound
677        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        // Check for TLSA-specific error first (takes priority)
691        if let Some(error) = self.tlsa_errors.get(&key) {
692            return Err(error.clone());
693        }
694
695        // Check for general FQDN error
696        if let Some(error) = self.errors.get(&fqdn.as_str().to_lowercase()) {
697            return Err(error.clone());
698        }
699
700        // Return configured records or NotFound
701        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        // Find v1.0.0
832        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        // Find v1.0.1
840        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        // Version not found
848        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    // -----------------------------------------------------------------
856    // DNSSEC string detection regression tests (C1 from REVIEW.md)
857    //
858    // These verify the string patterns used to detect DNSSEC errors from
859    // hickory-resolver Proto errors. If hickory changes its error wording,
860    // these tests should be updated to match.
861    // -----------------------------------------------------------------
862
863    #[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        // Known hickory-resolver error messages that should trigger DNSSEC
880        // detection via the string-matching fallback. If these fail after a
881        // hickory upgrade, update the patterns in lookup_tlsa().
882        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        // These should NOT be classified as DNSSEC errors
899        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    /// Integration test: DNSSEC-validating resolver rejects dnssec-failed.org.
922    ///
923    /// dnssec-failed.org has a valid A record but intentionally broken DNSSEC.
924    ///
925    /// In hickory 0.25, all DNS errors surface via `ResolveErrorKind::Proto`.
926    /// The upstream recursive resolver validates DNSSEC and returns ServFail
927    /// for bogus chains, which hickory wraps as `ProtoErrorKind::NoRecordsFound`
928    /// with `response_code: ServFail`. If hickory validates the chain itself
929    /// (CD=1), it produces typed `Nsec` or string-based DNSSEC errors.
930    #[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            // Upstream DNSSEC validation: resolver returns ServFail for bogus chain.
953            NetError::Dns(HickoryDnsError::NoRecordsFound(HickoryNoRecords {
954                response_code: ResponseCode::ServFail,
955                ..
956            })) => {}
957
958            // Typed DNSSEC negative response (NSEC/NSEC3 denial).
959            NetError::Dns(HickoryDnsError::Nsec { .. }) => {}
960
961            // Client-side DNSSEC validation via string-based error.
962            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        // TLSA can fail while badge lookups succeed
976        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        // Badge lookup succeeds
995        let badge_result = resolver.lookup_badge(&fqdn).await;
996        assert!(badge_result.is_ok());
997
998        // TLSA lookup fails with DNSSEC error
999        let tlsa_result = resolver.lookup_tlsa(&fqdn, 443).await;
1000        assert!(matches!(tlsa_result, Err(DnsError::DnssecFailed { .. })));
1001    }
1002
1003    // ── 4a: DnsResolverConfig presets ────────────────────────────────
1004
1005    #[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    // ── 4b: HickoryDnsResolver constructors ──────────────────────────
1049
1050    #[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    // ── 4c: DnsResolver trait default methods via MockDnsResolver ────
1104
1105    #[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        // The sort puts None-version records BEFORE versioned records
1190        // (they act as wildcards, so they get priority)
1191        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        // None-version records sort first (highest priority)
1203        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        // get_badge_records will return NotFound error
1212        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    // ── 4d: BadgeRecord accessors ────────────────────────────────────
1230
1231    #[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}