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::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/// Well-known DNS resolver configurations.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14#[non_exhaustive]
15pub enum DnsResolverConfig {
16    /// System default resolver configuration.
17    #[default]
18    System,
19    /// Cloudflare DNS (1.1.1.1, 1.0.0.1).
20    Cloudflare,
21    /// Cloudflare DNS over TLS.
22    CloudflareTls,
23    /// Google Public DNS (8.8.8.8, 8.8.4.4).
24    Google,
25    /// Google DNS over TLS.
26    GoogleTls,
27    /// Quad9 DNS (9.9.9.9) - includes malware blocking.
28    Quad9,
29    /// Quad9 DNS over TLS.
30    Quad9Tls,
31}
32
33impl DnsResolverConfig {
34    /// Convert to hickory `ResolverConfig` and `ResolverOpts`.
35    ///
36    /// For `System`, reads the OS DNS configuration (e.g., `/etc/resolv.conf`
37    /// on Linux, `scutil --dns` on macOS). Other presets return hardcoded
38    /// public DNS configurations.
39    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/// Parsed badge TXT record from `_ans-badge` or `_ra-badge` DNS records.
62///
63/// In production, construct via [`BadgeRecord::parse`]. A `BadgeRecord::new`
64/// constructor is available only when the `test-support` feature is enabled.
65#[derive(Debug, Clone)]
66pub struct BadgeRecord {
67    /// Format version (e.g., "ans-badge1" or "ra-badge1").
68    pub(crate) format_version: String,
69    /// Agent version this badge represents (optional - may not be in DNS record).
70    pub(crate) version: Option<Version>,
71    /// URL to fetch the badge from the transparency log.
72    pub(crate) url: String,
73}
74
75impl BadgeRecord {
76    /// Returns the format version (e.g., "ans-badge1" or "ra-badge1").
77    pub fn format_version(&self) -> &str {
78        &self.format_version
79    }
80
81    /// Returns the agent version this badge represents, if specified.
82    pub fn version(&self) -> Option<&Version> {
83        self.version.as_ref()
84    }
85
86    /// Returns the URL to fetch the badge from the transparency log.
87    pub fn url(&self) -> &str {
88        &self.url
89    }
90
91    /// Parse from TXT record string.
92    ///
93    /// Accepts both new and legacy formats:
94    /// - `v=ans-badge1; version=v1.0.0; url=https://...`
95    /// - `v=ra-badge1; version=v1.0.0; url=https://...`
96    /// - `v=ans-badge1;version=v1.0.0;url=https://...` (no spaces)
97    ///
98    /// Version field is optional.
99    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                // Validate URL syntax but store as String to avoid exposing url::Url
112                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    /// Create a `BadgeRecord` for testing.
139    ///
140    /// In production, use [`BadgeRecord::parse`] to construct from DNS TXT record data.
141    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/// DNS lookup result distinguishing between "not found" and "error".
155#[derive(Debug, Clone)]
156#[non_exhaustive]
157pub enum DnsLookupResult<T> {
158    /// Records were found.
159    Found(Vec<T>),
160    /// Record does not exist (NXDOMAIN).
161    NotFound,
162}
163
164/// DNS resolver trait for looking up badge records and TLSA records.
165///
166/// Badge records are queried from `_ans-badge.{fqdn}` (primary) with
167/// fallback to `_ra-badge.{fqdn}` (legacy).
168#[async_trait]
169pub trait DnsResolver: Send + Sync {
170    /// Query badge TXT records for an FQDN.
171    ///
172    /// Implementations should query `_ans-badge` first, falling back to `_ra-badge`.
173    async fn lookup_badge(&self, fqdn: &Fqdn) -> Result<DnsLookupResult<BadgeRecord>, DnsError>;
174
175    /// Query TLSA records for an FQDN and port.
176    ///
177    /// Returns TLSA records from `_<port>._tcp.<fqdn>`.
178    /// Used for DANE verification of server certificates.
179    async fn lookup_tlsa(
180        &self,
181        fqdn: &Fqdn,
182        port: u16,
183    ) -> Result<DnsLookupResult<TlsaRecord>, DnsError>;
184
185    /// Query all badge records and return them.
186    /// Convenience method that unwraps the result.
187    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    /// Get TLSA records, returning empty vec if not found.
197    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    /// Find the badge record matching a specific version.
206    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        // Find record with matching version, or if version is None in record, it matches any
213        Ok(records.into_iter().find(|r| {
214            match &r.version {
215                Some(v) => v == version,
216                None => true, // Record without version can match any version
217            }
218        }))
219    }
220
221    /// Find the first ACTIVE badge (or any if none specified as active).
222    /// During version changes, prefer newer versions.
223    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        // Sort by version descending (newest first), None versions go last
231        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
242/// DNS resolver implementation using hickory-resolver.
243pub struct HickoryDnsResolver {
244    resolver: TokioResolver,
245    /// Separate resolver with DNSSEC validation for TLSA lookups.
246    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)] // kept async for API consistency; callers use .await
256impl HickoryDnsResolver {
257    /// Create a new resolver with system configuration.
258    ///
259    /// Regular queries use the default resolver.
260    /// TLSA queries use a DNSSEC-validating resolver for security.
261    pub async fn new() -> Result<Self, DnsError> {
262        Self::with_preset(DnsResolverConfig::System).await
263    }
264
265    /// Create a resolver with a preset configuration (Cloudflare, Google, etc.).
266    ///
267    /// For `System`, reads the OS DNS configuration. This uses the actual
268    /// nameservers configured on the machine (not hardcoded Google DNS).
269    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        // Create DNSSEC-validating resolver for TLSA lookups
278        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    /// Create a resolver with custom nameserver IP addresses.
293    ///
294    /// # Example
295    /// ```rust,no_run
296    /// use ans_verify::HickoryDnsResolver;
297    /// use std::net::Ipv4Addr;
298    ///
299    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
300    /// // Use custom nameservers
301    /// let resolver = HickoryDnsResolver::with_nameservers(&[
302    ///     Ipv4Addr::new(1, 1, 1, 1),
303    ///     Ipv4Addr::new(8, 8, 8, 8),
304    /// ]).await?;
305    /// # Ok(())
306    /// # }
307    /// ```
308    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        // Create DNSSEC-validating resolver for TLSA lookups
322        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    /// Create a new resolver with custom configuration.
335    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        // Create DNSSEC-validating resolver for TLSA lookups
342        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    /// Create a resolver with DNSSEC validation enabled for all queries.
356    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    /// Query badge TXT records at a specific DNS name.
380    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)] // expanded error matching for hickory 0.25 ProtoErrorKind
442#[async_trait]
443impl DnsResolver for HickoryDnsResolver {
444    async fn lookup_badge(&self, fqdn: &Fqdn) -> Result<DnsLookupResult<BadgeRecord>, DnsError> {
445        // Try _ans-badge first (primary)
446        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                // Fall back to _ra-badge (legacy)
452                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        // Use DNSSEC-validating resolver for TLSA lookups
467        // This ensures TLSA records are protected by DNSSEC when available
468        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                            // ServFail from a DNSSEC-validating resolver typically means
480                            // the upstream rejected a bogus DNSSEC chain. Don't treat
481                            // this as "not found" — surface it as a DNSSEC failure.
482                            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                        // Typed DNSSEC negative response (new in hickory 0.25).
499                        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                        // Fallback: string match for untyped DNSSEC errors.
510                        _ => {
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        // Note: If we reach here, either:
544        // 1. DNSSEC validated successfully (secure)
545        // 2. Domain doesn't have DNSSEC (insecure but not bogus)
546        // Hickory doesn't easily expose which case we're in at the high-level API,
547        // so we log a general message. For domains without DNSSEC, the TLSA record
548        // provides no cryptographic binding guarantee.
549        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            // Build RDATA from TLSA record fields
558            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
596/// Returns true if the given error string contains patterns indicating a
597/// DNSSEC validation failure. Used as a fallback when hickory-resolver
598/// surfaces DNSSEC errors without a typed variant like `ProtoErrorKind::Nsec`.
599fn matches_dnssec_pattern(err_str: &str) -> bool {
600    err_str.contains("DNSSEC") || err_str.contains("validation")
601}
602
603/// Mock DNS resolver for testing.
604#[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    /// Create a new mock resolver.
616    pub fn new() -> Self {
617        Self::default()
618    }
619
620    /// Add badge records for an FQDN.
621    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    /// Add TLSA records for an FQDN and port.
627    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    /// Configure an error for an FQDN.
634    pub fn with_error(mut self, fqdn: &str, error: DnsError) -> Self {
635        self.errors.insert(fqdn.to_lowercase(), error);
636        self
637    }
638
639    /// Configure a TLSA-specific error for an FQDN and port.
640    ///
641    /// This allows TLSA lookups to fail independently of badge lookups.
642    /// Useful for testing DNSSEC validation failures on TLSA records
643    /// while badge DNS lookups succeed normally.
644    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        // Check for configured error first
658        if let Some(error) = self.errors.get(&key) {
659            return Err(error.clone());
660        }
661
662        // Return configured records or NotFound
663        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        // Check for TLSA-specific error first (takes priority)
677        if let Some(error) = self.tlsa_errors.get(&key) {
678            return Err(error.clone());
679        }
680
681        // Check for general FQDN error
682        if let Some(error) = self.errors.get(&fqdn.as_str().to_lowercase()) {
683            return Err(error.clone());
684        }
685
686        // Return configured records or NotFound
687        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        // Find v1.0.0
818        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        // Find v1.0.1
826        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        // Version not found
834        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    // -----------------------------------------------------------------
842    // DNSSEC string detection regression tests (C1 from REVIEW.md)
843    //
844    // These verify the string patterns used to detect DNSSEC errors from
845    // hickory-resolver Proto errors. If hickory changes its error wording,
846    // these tests should be updated to match.
847    // -----------------------------------------------------------------
848
849    #[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        // Known hickory-resolver error messages that should trigger DNSSEC
866        // detection via the string-matching fallback. If these fail after a
867        // hickory upgrade, update the patterns in lookup_tlsa().
868        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        // These should NOT be classified as DNSSEC errors
885        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    /// Integration test: DNSSEC-validating resolver rejects dnssec-failed.org.
908    ///
909    /// dnssec-failed.org has a valid A record but intentionally broken DNSSEC.
910    ///
911    /// In hickory 0.25, all DNS errors surface via `ResolveErrorKind::Proto`.
912    /// The upstream recursive resolver validates DNSSEC and returns ServFail
913    /// for bogus chains, which hickory wraps as `ProtoErrorKind::NoRecordsFound`
914    /// with `response_code: ServFail`. If hickory validates the chain itself
915    /// (CD=1), it produces typed `Nsec` or string-based DNSSEC errors.
916    #[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                // Upstream DNSSEC validation: resolver returns ServFail for bogus chain.
938                ProtoErrorKind::NoRecordsFound { response_code, .. }
939                    if *response_code == ResponseCode::ServFail => {}
940
941                // Typed DNSSEC negative response (new in hickory 0.25).
942                ProtoErrorKind::Nsec { .. } => {}
943
944                // Client-side DNSSEC validation via string-based Proto error.
945                _ => {
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        // TLSA can fail while badge lookups succeed
968        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        // Badge lookup succeeds
987        let badge_result = resolver.lookup_badge(&fqdn).await;
988        assert!(badge_result.is_ok());
989
990        // TLSA lookup fails with DNSSEC error
991        let tlsa_result = resolver.lookup_tlsa(&fqdn, 443).await;
992        assert!(matches!(tlsa_result, Err(DnsError::DnssecFailed { .. })));
993    }
994
995    // ── 4a: DnsResolverConfig presets ────────────────────────────────
996
997    #[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    // ── 4b: HickoryDnsResolver constructors ──────────────────────────
1041
1042    #[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    // ── 4c: DnsResolver trait default methods via MockDnsResolver ────
1094
1095    #[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        // The sort puts None-version records BEFORE versioned records
1180        // (they act as wildcards, so they get priority)
1181        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        // None-version records sort first (highest priority)
1193        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        // get_badge_records will return NotFound error
1202        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    // ── 4d: BadgeRecord accessors ────────────────────────────────────
1220
1221    #[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}