Skip to main content

aria2_core/dns/
dns_cache.rs

1//! DNS Cache Module
2//!
3//! Provides DNS resolution caching with TTL support, negative caching for failed
4//! lookups (to prevent retry storms), and IPv4/IPv6 preference sorting.
5//!
6//! # Features
7//!
8//! - **TTL-based expiration**: Cached entries expire after a configurable time-to-live
9//! - **Negative caching**: Failed lookups are remembered to avoid immediate retries
10//! - **IPv4 preference**: Addresses can be sorted with IPv4 first (matching C++ aria2 behavior)
11//! - **Dependency injection**: Cache instances are created during engine initialization
12//!   and passed down, avoiding global mutable state
13//!
14//! # Example
15//!
16//! ```rust,no_run
17//! use aria2_core::dns::dns_cache::DnsCache;
18//!
19//! #[tokio::main]
20//! async fn main() {
21//!     let mut cache = DnsCache::with_ttl(300, 60);
22//!     match cache.resolve("example.com", 80).await {
23//!         Ok(addrs) => println!("Resolved: {:?}", addrs),
24//!         Err(e) => eprintln!("DNS error: {}", e),
25//!     }
26//! }
27//! ```
28
29use std::collections::HashMap;
30use std::net::{IpAddr, SocketAddr};
31use std::time::{Duration, Instant};
32
33use hickory_resolver::TokioAsyncResolver;
34use hickory_resolver::config::{ResolverConfig, ResolverOpts};
35
36/// A single cached DNS entry containing resolved addresses and metadata.
37///
38/// Each entry stores the resolved socket addresses for a hostname,
39/// along with when it was resolved and its time-to-live duration.
40#[derive(Debug, Clone)]
41pub struct DnsEntry {
42    /// The hostname this entry was resolved for
43    pub hostname: String,
44    /// Resolved socket addresses (sorted by preference)
45    pub addresses: Vec<SocketAddr>,
46    /// Timestamp when this entry was created/resolved
47    pub resolved_at: Instant,
48    /// Time-to-live for this entry before it's considered stale
49    pub ttl: Duration,
50    /// Whether IPv4 addresses should be preferred in ordering
51    pub ipv4_preferred: bool,
52}
53
54impl DnsEntry {
55    /// Check if this DNS entry has expired based on its TTL.
56    ///
57    /// Returns `true` if the elapsed time since resolution exceeds the TTL,
58    /// meaning the entry should be re-resolved.
59    pub fn is_expired(&self) -> bool {
60        self.resolved_at.elapsed() > self.ttl
61    }
62
63    /// Get the best address from this entry.
64    ///
65    /// If IPv4 is preferred, returns the first IPv4 address if available,
66    /// otherwise falls back to the first address in the list.
67    /// Returns `None` if there are no addresses.
68    pub fn best_address(&self) -> Option<SocketAddr> {
69        if self.addresses.is_empty() {
70            return None;
71        }
72        if self.ipv4_preferred {
73            self.addresses
74                .iter()
75                .find(|a| matches!(a.ip(), IpAddr::V4(_)))
76                .copied()
77                .or_else(|| self.addresses.first().copied())
78        } else {
79            Some(self.addresses[0])
80        }
81    }
82
83    /// Return a clone of all cached addresses for this entry.
84    pub fn all_addresses(&self) -> Vec<SocketAddr> {
85        self.addresses.clone()
86    }
87}
88
89/// A DNS resolution cache with TTL support and negative caching.
90///
91/// This cache stores resolved DNS entries and avoids repeated lookups
92/// for the same hostname within the TTL window. It also implements
93/// negative caching for failed lookups to prevent retry storms.
94///
95/// # Thread Safety
96///
97/// For use in async contexts, wrap with `tokio::sync::Mutex`.
98/// Instances should be created during engine initialization and passed
99/// down via dependency injection rather than using global singletons.
100pub struct DnsCache {
101    /// Cache of successful DNS resolutions: hostname -> DnsEntry
102    cache: HashMap<String, DnsEntry>,
103    /// Default TTL for successfully resolved entries
104    default_ttl: Duration,
105    /// TTL for failed/negative lookups (prevents retry storms)
106    negative_ttl: Duration,
107    /// Negative cache: hostname -> timestamp of last failed lookup
108    negative_entries: HashMap<String, Instant>,
109    /// Whether to prefer IPv4 addresses when sorting results
110    ipv4_preference: bool,
111    /// Fully-async hickory DNS resolver. When `None` (or on lookup error) resolution
112    /// falls back to `tokio::net::lookup_host`. `TokioAsyncResolver` is `Arc`-backed
113    /// internally, so cloning is cheap and no outer `Arc` is required.
114    resolver: Option<TokioAsyncResolver>,
115}
116
117impl DnsCache {
118    /// Create a new DNS cache with default settings.
119    ///
120    /// Default values:
121    /// - TTL: 300 seconds (5 minutes)
122    /// - Negative TTL: 60 seconds (1 minute)
123    /// - IPv4 preference: enabled
124    pub fn new() -> Self {
125        Self {
126            cache: HashMap::new(),
127            default_ttl: Duration::from_secs(300), // 5 minutes default
128            negative_ttl: Duration::from_secs(60), // 1 minute for failures
129            negative_entries: HashMap::new(),
130            ipv4_preference: true, // Prefer IPv4 by default (like C++ aria2)
131            resolver: build_default_resolver(),
132        }
133    }
134
135    /// Create a new DNS cache with custom TTL values.
136    ///
137    /// # Arguments
138    ///
139    /// * `default_ttl_secs` - Time-to-live for successful resolutions (in seconds)
140    /// * `negative_ttl_secs` - Time-to-live for failed lookups (in seconds)
141    pub fn with_ttl(default_ttl_secs: u64, negative_ttl_secs: u64) -> Self {
142        Self {
143            default_ttl: Duration::from_secs(default_ttl_secs),
144            negative_ttl: Duration::from_secs(negative_ttl_secs),
145            ..Self::new()
146        }
147    }
148
149    /// Inject a custom hickory resolver (useful for testing with a configured/mock resolver).
150    ///
151    /// This replaces the default resolver built during construction. The TTL settings and
152    /// IPv4 preference are preserved.
153    pub fn with_resolver(mut self, resolver: TokioAsyncResolver) -> Self {
154        self.resolver = Some(resolver);
155        self
156    }
157
158    /// Resolve a hostname to socket addresses, using cache if valid.
159    ///
160    /// Resolution strategy:
161    /// 1. Check positive cache — return immediately if entry exists and is not expired
162    /// 2. Check negative cache — return error if lookup recently failed
163    /// 3. Try the fully-async hickory resolver (no blocking getaddrinfo); on success the
164    ///    record TTL is capped at `default_ttl` and the result is cached
165    /// 4. If hickory is unavailable or errors, fall back to `tokio::net::lookup_host`
166    ///    (blocking getaddrinfo on a tokio thread pool) so behavior stays robust
167    /// 5. On success: sort addresses by IPv4 preference, cache result, return
168    /// 6. On failure: record in negative cache, return error
169    ///
170    /// # Arguments
171    ///
172    /// * `hostname` - The hostname to resolve (e.g., "example.com")
173    /// * `port` - The port number to include in resolved addresses
174    ///
175    /// # Returns
176    ///
177    /// A vector of resolved `SocketAddr` on success, or an error string on failure.
178    pub async fn resolve(&mut self, hostname: &str, port: u16) -> Result<Vec<SocketAddr>, String> {
179        // 1. Check positive cache first
180        if let Some(entry) = self.cache.get(hostname)
181            && !entry.is_expired()
182        {
183            return Ok(entry.all_addresses());
184        }
185
186        // 2. Check negative cache (recently failed lookup)
187        if let Some(failed_at) = self.negative_entries.get(hostname)
188            && failed_at.elapsed() < self.negative_ttl
189        {
190            return Err(format!(
191                "DNS lookup recently failed for {} (retry after {:?})",
192                hostname,
193                self.negative_ttl.saturating_sub(failed_at.elapsed())
194            ));
195        }
196
197        // 3. Try the fully-async hickory resolver first (avoids blocking getaddrinfo).
198        if let Some(resolver) = self.resolver.as_ref() {
199            match resolver.lookup_ip(hostname).await {
200                Ok(lookup) => {
201                    let mut addrs: Vec<SocketAddr> =
202                        lookup.iter().map(|ip| SocketAddr::new(ip, port)).collect();
203
204                    if !addrs.is_empty() {
205                        // Derive TTL from the lookup's remaining validity, capped at default_ttl.
206                        let record_ttl = lookup
207                            .valid_until()
208                            .saturating_duration_since(Instant::now());
209                        let actual_ttl = record_ttl.min(self.default_ttl);
210
211                        if self.ipv4_preference {
212                            addrs.sort_by_key(|a| match a.ip() {
213                                IpAddr::V4(_) => 0u8,
214                                IpAddr::V6(_) => 1u8,
215                            });
216                        }
217
218                        let hostname_owned = hostname.to_string();
219                        let entry = DnsEntry {
220                            hostname: hostname_owned.clone(),
221                            addresses: addrs.clone(),
222                            resolved_at: Instant::now(),
223                            ttl: actual_ttl,
224                            ipv4_preferred: self.ipv4_preference,
225                        };
226                        self.cache.insert(hostname_owned, entry);
227                        self.negative_entries.remove(hostname);
228
229                        tracing::trace!(
230                            hostname = hostname,
231                            addr_count = addrs.len(),
232                            ttl_secs = actual_ttl.as_secs(),
233                            "DNS resolved via hickory async resolver"
234                        );
235                        return Ok(addrs);
236                    }
237
238                    tracing::debug!(
239                        hostname = hostname,
240                        "hickory returned no addresses, falling back to tokio::net::lookup_host"
241                    );
242                    // Fall through to the OS-level fallback below.
243                }
244                Err(e) => {
245                    tracing::debug!(
246                        hostname = hostname,
247                        error = %e,
248                        "hickory DNS lookup failed, falling back to tokio::net::lookup_host"
249                    );
250                    // Fall through to the OS-level fallback below.
251                }
252            }
253        }
254
255        // 4. Fallback: OS-level resolution via tokio (blocking getaddrinfo on a thread pool).
256        //    Used when the hickory resolver is unavailable, returns no addresses, or errors.
257        let addr_str = format!("{}:{}", hostname, port);
258        match tokio::net::lookup_host(&addr_str).await {
259            Ok(addrs) => {
260                let mut sorted: Vec<SocketAddr> = addrs.collect();
261
262                // Sort: IPv4 first if preferred, then by address family
263                if self.ipv4_preference {
264                    sorted.sort_by_key(|a| match a.ip() {
265                        IpAddr::V4(_) => 0u8,
266                        IpAddr::V6(_) => 1u8,
267                    });
268                }
269
270                let hostname_owned = hostname.to_string();
271                let entry = DnsEntry {
272                    hostname: hostname_owned.clone(),
273                    addresses: sorted.clone(),
274                    resolved_at: Instant::now(),
275                    ttl: self.default_ttl,
276                    ipv4_preferred: self.ipv4_preference,
277                };
278                self.cache.insert(hostname_owned, entry);
279                self.negative_entries.remove(hostname);
280
281                tracing::trace!(
282                    hostname = hostname,
283                    addr_count = sorted.len(),
284                    "DNS resolved via tokio::net::lookup_host fallback"
285                );
286                Ok(sorted)
287            }
288            Err(e) => {
289                // Record failure in negative cache to prevent retry storms
290                self.negative_entries
291                    .insert(hostname.to_string(), Instant::now());
292                tracing::debug!(
293                    hostname = hostname,
294                    error = %e,
295                    "DNS resolution failed via fallback"
296                );
297                Err(e.to_string())
298            }
299        }
300    }
301
302    /// Force refresh a specific hostname, bypassing any cached entry.
303    ///
304    /// This removes any existing cache entry for the hostname and performs
305    /// a fresh DNS resolution. Useful when you know the DNS records may have changed.
306    ///
307    /// # Arguments
308    ///
309    /// * `hostname` - The hostname to re-resolve
310    /// * `port` - The port number for resolved addresses
311    pub async fn force_refresh(
312        &mut self,
313        hostname: &str,
314        port: u16,
315    ) -> Result<Vec<SocketAddr>, String> {
316        self.cache.remove(hostname);
317        self.resolve(hostname, port).await
318    }
319
320    /// Clear all cached entries (both positive and negative).
321    pub fn clear(&mut self) {
322        self.cache.clear();
323        self.negative_entries.clear();
324    }
325
326    /// Remove expired entries from the cache.
327    ///
328    /// Call this periodically (e.g., every few minutes) to reclaim memory
329    /// from stale entries. Also cleans up expired negative cache entries.
330    ///
331    /// # Returns
332    ///
333    /// The number of entries that were removed.
334    pub fn purge_expired(&mut self) -> usize {
335        let before = self.cache.len();
336        self.cache.retain(|_, v| !v.is_expired());
337        self.negative_entries
338            .retain(|_, t| t.elapsed() < self.negative_ttl);
339        before - self.cache.len()
340    }
341
342    /// Set whether IPv4 addresses should be preferred over IPv6.
343    ///
344    /// When enabled, resolved addresses are sorted with IPv4 addresses first.
345    /// This matches the behavior of C++ aria2 which prefers IPv4 by default.
346    pub fn set_ipv4_preference(&mut self, prefer_ipv4: bool) {
347        self.ipv4_preference = prefer_ipv4;
348    }
349
350    /// Get the number of currently cached (non-expired) entries.
351    pub fn len(&self) -> usize {
352        self.cache.len()
353    }
354
355    /// Check if the cache contains no entries.
356    pub fn is_empty(&self) -> bool {
357        self.cache.is_empty()
358    }
359
360    /// Get the default TTL setting.
361    pub fn default_ttl(&self) -> Duration {
362        self.default_ttl
363    }
364
365    /// Get the negative TTL setting.
366    pub fn negative_ttl(&self) -> Duration {
367        self.negative_ttl
368    }
369
370    /// Manually record a failed lookup in the negative cache.
371    ///
372    /// This is useful for testing negative cache behavior without
373    /// depending on network DNS resolution.
374    pub fn record_failure(&mut self, hostname: &str) {
375        self.negative_entries
376            .insert(hostname.to_string(), Instant::now());
377    }
378
379    /// Check cache only (no network resolution).
380    ///
381    /// Returns cached addresses if available, or an error if the entry
382    /// is in the negative cache or not cached at all. This is useful
383    /// for testing cache behavior without network dependencies.
384    pub fn resolve_no_network(
385        &mut self,
386        hostname: &str,
387        port: u16,
388    ) -> Result<Vec<SocketAddr>, String> {
389        // 1. Check positive cache first
390        if let Some(entry) = self.cache.get(hostname)
391            && !entry.is_expired()
392        {
393            return Ok(entry.all_addresses());
394        }
395
396        // 2. Check negative cache (recently failed lookup)
397        if let Some(failed_at) = self.negative_entries.get(hostname)
398            && failed_at.elapsed() < self.negative_ttl
399        {
400            return Err(format!(
401                "DNS lookup recently failed for {} (retry after {:?})",
402                hostname,
403                self.negative_ttl.saturating_sub(failed_at.elapsed())
404            ));
405        }
406
407        Err(format!("No cached entry for {}:{}", hostname, port))
408    }
409}
410
411/// Build the default hickory async resolver.
412///
413/// Uses the default upstream configuration (Google Public DNS via `ResolverConfig::default()`)
414/// and enables `use_hosts_file` so that names present in the system hosts file (e.g.
415/// `localhost`) are answered immediately without any network I/O — mirroring the behavior of
416/// `getaddrinfo` used by `tokio::net::lookup_host`.
417///
418/// `TokioAsyncResolver::tokio` returns a ready handle (it does not spawn a background task at
419/// construction time and captures no runtime handle), so this is safe to call outside of an
420/// async context. The returned value is wrapped in `Some`; callers fall back to
421/// `tokio::net::lookup_host` whenever the resolver is `None`.
422fn build_default_resolver() -> Option<TokioAsyncResolver> {
423    let mut opts = ResolverOpts::default();
424    opts.use_hosts_file = true;
425    Some(TokioAsyncResolver::tokio(ResolverConfig::default(), opts))
426}
427
428impl Default for DnsCache {
429    fn default() -> Self {
430        Self::new()
431    }
432}
433
434// ==================== Tests ====================
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    /// Helper: create a test cache with very short TTLs for fast testing
441    fn create_test_cache() -> DnsCache {
442        DnsCache::with_ttl(10, 1) // 10s positive TTL, 1s negative TTL
443    }
444
445    #[test]
446    fn test_dns_entry_is_expired() {
447        let entry = DnsEntry {
448            hostname: "test.com".to_string(),
449            addresses: vec![],
450            resolved_at: Instant::now(),
451            ttl: Duration::from_secs(60),
452            ipv4_preferred: true,
453        };
454        assert!(!entry.is_expired());
455
456        let expired_entry = DnsEntry {
457            hostname: "old.com".to_string(),
458            addresses: vec![],
459            resolved_at: Instant::now() - Duration::from_secs(61),
460            ttl: Duration::from_secs(60),
461            ipv4_preferred: false,
462        };
463        assert!(expired_entry.is_expired());
464    }
465
466    #[test]
467    fn test_dns_entry_best_address_ipv4_preferred() {
468        let ipv6_addr: SocketAddr = "[::1]:8080".parse().unwrap();
469        let ipv4_addr: SocketAddr = "127.0.0.1:8080".parse().unwrap();
470
471        let entry = DnsEntry {
472            hostname: "mixed.com".to_string(),
473            addresses: vec![ipv6_addr, ipv4_addr], // IPv6 first
474            resolved_at: Instant::now(),
475            ttl: Duration::from_secs(60),
476            ipv4_preferred: true,
477        };
478
479        // Should prefer IPv4 even though it's second in list
480        let best = entry.best_address().unwrap();
481        assert_eq!(best, ipv4_addr);
482    }
483
484    #[test]
485    fn test_dns_entry_best_address_no_ipv4() {
486        let ipv6_addr: SocketAddr = "[::1]:8080".parse().unwrap();
487
488        let entry = DnsEntry {
489            hostname: "ipv6only.com".to_string(),
490            addresses: vec![ipv6_addr],
491            resolved_at: Instant::now(),
492            ttl: Duration::from_secs(60),
493            ipv4_preferred: true,
494        };
495
496        let best = entry.best_address().unwrap();
497        assert_eq!(best, ipv6_addr);
498    }
499
500    #[test]
501    fn test_dns_entry_best_address_empty() {
502        let entry = DnsEntry {
503            hostname: "empty.com".to_string(),
504            addresses: vec![],
505            resolved_at: Instant::now(),
506            ttl: Duration::from_secs(60),
507            ipv4_preferred: true,
508        };
509
510        assert!(entry.best_address().is_none());
511    }
512
513    #[test]
514    fn test_dns_cache_creation() {
515        let cache = DnsCache::new();
516        assert!(cache.is_empty());
517        assert_eq!(cache.default_ttl(), Duration::from_secs(300));
518        assert_eq!(cache.negative_ttl(), Duration::from_secs(60));
519    }
520
521    #[test]
522    fn test_dns_cache_with_custom_ttl() {
523        let cache = DnsCache::with_ttl(600, 30);
524        assert_eq!(cache.default_ttl(), Duration::from_secs(600));
525        assert_eq!(cache.negative_ttl(), Duration::from_secs(30));
526    }
527
528    #[test]
529    fn test_dns_cache_clear() {
530        let mut cache = create_test_cache();
531        // Manually insert something into the cache
532        let entry = DnsEntry {
533            hostname: "example.com".to_string(),
534            addresses: vec!["127.0.0.1:80".parse().unwrap()],
535            resolved_at: Instant::now(),
536            ttl: Duration::from_secs(60),
537            ipv4_preferred: true,
538        };
539        cache.cache.insert("example.com".to_string(), entry);
540        cache
541            .negative_entries
542            .insert("failed.com".to_string(), Instant::now());
543
544        assert_eq!(cache.len(), 1);
545        cache.clear();
546        assert!(cache.is_empty());
547        assert!(cache.negative_entries.is_empty());
548    }
549
550    /// Test J3.4 #1: Second call returns cached result without network I/O.
551    ///
552    /// We use localhost which resolves instantly and verify that once cached,
553    /// subsequent calls return the same data without needing actual network calls.
554    /// Since we can't easily mock tokio::net::lookup_host in unit tests,
555    /// we verify the caching mechanism directly by manipulating the internal state.
556    #[tokio::test]
557    async fn test_resolve_caches_result() {
558        let mut cache = create_test_cache();
559
560        // Resolve localhost (should always succeed)
561        let result1 = cache.resolve("localhost", 80).await;
562        assert!(
563            result1.is_ok(),
564            "First resolve of localhost should succeed: {:?}",
565            result1.err()
566        );
567        let addrs1 = result1.unwrap();
568        assert!(
569            !addrs1.is_empty(),
570            "localhost should resolve to at least one address"
571        );
572
573        // Second resolve should hit cache (same result, no network call)
574        let result2 = cache.resolve("localhost", 80).await;
575        assert!(result2.is_ok(), "Second resolve should succeed from cache");
576        let addrs2 = result2.unwrap();
577        assert_eq!(
578            addrs1, addrs2,
579            "Cached result should match original resolution"
580        );
581
582        // Verify cache now has exactly one entry
583        assert_eq!(cache.len(), 1);
584    }
585
586    /// Test J3.4 #2: Failed lookup blocks retry for negative_ttl duration.
587    ///
588    /// We inject a negative cache entry directly (without network DNS)
589    /// and verify that subsequent attempts within the negative TTL window
590    /// fail immediately with the "recently failed" message.
591    #[test]
592    fn test_negative_cache_blocks_retry() {
593        let mut cache = DnsCache::with_ttl(300, 2); // 2-second negative TTL
594
595        // Inject a negative cache entry directly (no network dependency)
596        cache.record_failure("test-host.invalid");
597
598        // Immediate retry should be blocked by negative cache
599        let result = cache.resolve_no_network("test-host.invalid", 80);
600        assert!(
601            result.is_err(),
602            "Lookup should be blocked by negative cache"
603        );
604        let err_msg = result.unwrap_err();
605        assert!(
606            err_msg.contains("recently failed"),
607            "Error should mention recent failure: {}",
608            err_msg
609        );
610    }
611
612    /// Test J3.4 #3: Expired entries are removed by purge_expired().
613    ///
614    /// We insert entries with already-expired timestamps and verify that
615    /// purge_expired removes them while keeping valid entries intact.
616    #[test]
617    fn test_purge_expired_removes_old() {
618        let mut cache = DnsCache::with_ttl(1, 60); // 1-second TTL
619
620        // Insert an already-expired entry
621        let expired_entry = DnsEntry {
622            hostname: "expired.example.com".to_string(),
623            addresses: vec!["10.0.0.1:80".parse().unwrap()],
624            resolved_at: Instant::now() - Duration::from_secs(5), // Expired 5 seconds ago
625            ttl: Duration::from_secs(1),
626            ipv4_preferred: true,
627        };
628        cache
629            .cache
630            .insert("expired.example.com".to_string(), expired_entry);
631
632        // Insert a still-valid entry
633        let fresh_entry = DnsEntry {
634            hostname: "fresh.example.com".to_string(),
635            addresses: vec!["10.0.0.2:80".parse().unwrap()],
636            resolved_at: Instant::now(),
637            ttl: Duration::from_secs(3600), // 1 hour TTL
638            ipv4_preferred: true,
639        };
640        cache
641            .cache
642            .insert("fresh.example.com".to_string(), fresh_entry);
643
644        assert_eq!(cache.len(), 2, "Should have 2 entries before purge");
645
646        let removed = cache.purge_expired();
647        assert_eq!(removed, 1, "Should remove exactly 1 expired entry");
648        assert_eq!(cache.len(), 1, "Should have 1 entry remaining");
649        assert!(
650            cache.cache.contains_key("fresh.example.com"),
651            "Fresh entry should still exist"
652        );
653        assert!(
654            !cache.cache.contains_key("expired.example.com"),
655            "Expired entry should be removed"
656        );
657    }
658
659    /// Test J3.4 #4: IPv4 addresses come first when ipv4_preference is enabled.
660    ///
661    /// Verifies that when IPv4 preference is set, the DnsCache sorts resolved
662    /// addresses so that IPv4 addresses appear before IPv6 addresses.
663    #[tokio::test]
664    async fn test_ipv4_preferred_sorting() {
665        let mut cache = create_test_cache();
666
667        // Resolve localhost which typically returns both [::1] and 127.0.0.1
668        let result = cache.resolve("localhost", 8080).await;
669        assert!(
670            result.is_ok(),
671            "localhost resolution should succeed: {:?}",
672            result.err()
673        );
674        let addrs = result.unwrap();
675
676        // If we have both IPv4 and IPv6 addresses, IPv4 should come first
677        let has_ipv4 = addrs.iter().any(|a| matches!(a.ip(), IpAddr::V4(_)));
678        let has_ipv6 = addrs.iter().any(|a| matches!(a.ip(), IpAddr::V6(_)));
679
680        if has_ipv4 && has_ipv6 {
681            let first_ipv4_pos = addrs
682                .iter()
683                .position(|a| matches!(a.ip(), IpAddr::V4(_)))
684                .unwrap();
685            let first_ipv6_pos = addrs
686                .iter()
687                .position(|a| matches!(a.ip(), IpAddr::V6(_)))
688                .unwrap();
689            assert!(
690                first_ipv4_pos < first_ipv6_pos,
691                "IPv4 addresses should come before IPv6 when preferred. Got order: {:?}",
692                addrs
693            );
694        }
695
696        // Verify we can also disable IPv4 preference
697        cache.set_ipv4_preference(false);
698        // Re-resolve with different preference
699        let result2 = cache.force_refresh("localhost", 8080).await;
700        assert!(result2.is_ok(), "Force refresh should succeed");
701    }
702
703    #[test]
704    fn test_force_refresh_clears_cache_entry() {
705        // Note: This test only verifies the cache clearing logic,
706        // not the full async resolution (which requires tokio runtime)
707        let mut cache = create_test_cache();
708
709        // Manually pre-populate cache
710        let entry = DnsEntry {
711            hostname: "preloaded.com".to_string(),
712            addresses: vec!["192.168.1.1:443".parse().unwrap()],
713            resolved_at: Instant::now(),
714            ttl: Duration::from_secs(3600),
715            ipv4_preferred: true,
716        };
717        cache.cache.insert("preloaded.com".to_string(), entry);
718        assert_eq!(cache.len(), 1);
719
720        // force_refresh should remove the existing entry (we don't await here
721        // because this is a sync test; the removal happens before the async resolve)
722        // In practice, the cache.remove() is called synchronously at the start
723        // of force_refresh, so we can verify the entry would be removed
724    }
725
726    #[test]
727    fn test_default_impl() {
728        let cache = DnsCache::default();
729        assert!(cache.is_empty());
730        assert_eq!(cache.default_ttl(), Duration::from_secs(300));
731    }
732
733    /// Test D1 #1: the hickory async resolver resolves "localhost" and caches it.
734    ///
735    /// "localhost" is answered from the system hosts file (use_hosts_file is enabled), so this
736    /// exercises the hickory code path without depending on external network reachability.
737    #[tokio::test]
738    async fn test_async_dns_resolves_localhost() {
739        let mut cache = DnsCache::with_ttl(300, 60);
740        let result = cache.resolve("localhost", 80).await;
741        assert!(
742            result.is_ok(),
743            "localhost should resolve: {:?}",
744            result.err()
745        );
746        let addrs = result.unwrap();
747        assert!(!addrs.is_empty(), "should have at least one address");
748        // Verify it's cached.
749        assert_eq!(cache.len(), 1, "localhost should be cached after resolve");
750    }
751
752    /// Test D1 #2: the TTL reported by the resolver is capped at default_ttl.
753    ///
754    /// Resolves "localhost" (from the hosts file, whose entries carry a very large TTL) with a
755    /// 60s default TTL and verifies the cached entry's TTL does not exceed the default.
756    #[tokio::test]
757    async fn test_dns_ttl_capped_at_default() {
758        let mut cache = DnsCache::with_ttl(60, 10); // default 60s
759        let _ = cache.resolve("localhost", 80).await;
760        // Verify the cached entry has ttl <= 60s (capped at default_ttl).
761        if let Some(entry) = cache.cache.get("localhost") {
762            assert!(
763                entry.ttl <= Duration::from_secs(60),
764                "ttl should be capped at default, got {:?}",
765                entry.ttl
766            );
767        }
768    }
769}