dnscrypt 0.1.1

A pure-Rust DNSCrypt v2 client library — sync and async support.
Documentation
//! Small fixed-TTL cache for resolved IP addresses.
//!
//! This is used only by the high-level convenience entry points
//! ([`crate::resolve`], [`crate::resolve_async`], and [`crate::DnscryptResolver`])
//! so that repeated lookups of the same domain in quick succession (e.g. a
//! browser-like client re-resolving a host for several connections) don't
//! each pay for a full `DNSCrypt` round trip. Lower-level session APIs
//! ([`crate::resolve_domain_via_dnscrypt_session`]) intentionally bypass
//! this cache so callers that manage sessions themselves always see a live
//! answer.
//!
//! The cache is deliberately simple: a fixed TTL per entry rather than one
//! derived from the DNS response's own TTL field, and a hard cap on the
//! number of entries with oldest-first eviction once that cap is reached.

use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};

/// How long a resolved answer is considered fresh.
const ANSWER_TTL: Duration = Duration::from_secs(30);
/// Hard cap on the number of cached domains; bounds memory use if a caller
/// resolves a large number of distinct domains.
const MAX_ENTRIES: usize = 512;

struct Entry {
    ips: Vec<IpAddr>,
    expires_at: Instant,
    inserted_at: Instant,
}

fn cache() -> &'static Mutex<HashMap<String, Entry>> {
    static CACHE: OnceLock<Mutex<HashMap<String, Entry>>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Look up a still-fresh cached answer for `domain`.
#[allow(clippy::redundant_pub_crate)] // unreachable_pub requires pub(crate) here; this module is crate-private either way
pub(crate) fn get(domain: &str) -> Option<Vec<IpAddr>> {
    let key = domain.to_ascii_lowercase();
    let map = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
    map.get(&key).and_then(|entry| {
        if entry.expires_at > Instant::now() {
            Some(entry.ips.clone())
        } else {
            None
        }
    })
}

/// Store a freshly resolved answer for `domain`.
#[allow(clippy::redundant_pub_crate)] // unreachable_pub requires pub(crate) here; this module is crate-private either way
pub(crate) fn put(domain: &str, ips: Vec<IpAddr>) {
    let key = domain.to_ascii_lowercase();
    let mut map = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);

    if map.len() >= MAX_ENTRIES && !map.contains_key(&key) {
        if let Some(oldest_key) = map
            .iter()
            .min_by_key(|(_, entry)| entry.inserted_at)
            .map(|(k, _)| k.clone())
        {
            map.remove(&oldest_key);
        }
    }

    let now = Instant::now();
    map.insert(
        key,
        Entry {
            ips,
            expires_at: now + ANSWER_TTL,
            inserted_at: now,
        },
    );
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use std::net::Ipv4Addr;

    #[test]
    fn test_cache_roundtrip_and_case_insensitivity() {
        let domain = format!("cache-test-{:?}.example.", std::thread::current().id());
        let ips = vec![IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))];
        put(&domain, ips.clone());
        assert_eq!(get(&domain.to_uppercase()), Some(ips));
    }

    #[test]
    fn test_cache_miss_for_unknown_domain() {
        assert_eq!(get("definitely-not-cached.invalid."), None);
    }

    #[test]
    fn test_cache_eviction_at_capacity() {
        // Fill the cache past MAX_ENTRIES with entries unique to this test so
        // the oldest ones (inserted first) are forced out, then confirm a
        // freshly re-put entry survives and the cache never exceeds its cap.
        let prefix = format!("evict-test-{:?}-", std::thread::current().id());
        for i in 0..MAX_ENTRIES + 8 {
            put(
                &format!("{prefix}{i}.example."),
                vec![IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))],
            );
        }

        let last = format!("{prefix}{}.example.", MAX_ENTRIES + 7);
        assert!(get(&last).is_some());
        assert!(cache().lock().unwrap().len() <= MAX_ENTRIES);
    }
}