Skip to main content

seer_core/rdap/
client.rs

1use std::collections::HashMap;
2use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use futures::StreamExt;
7use once_cell::sync::Lazy;
8use reqwest::Client;
9use serde::Deserialize;
10use tokio::sync::RwLock;
11use tracing::{debug, instrument, warn};
12
13use super::types::RdapResponse;
14use crate::error::{Result, SeerError};
15use crate::retry::{RetryExecutor, RetryPolicy};
16use crate::validation::{describe_reserved_ip, normalize_domain};
17
18const IANA_BOOTSTRAP_DNS: &str = "https://data.iana.org/rdap/dns.json";
19const IANA_BOOTSTRAP_IPV4: &str = "https://data.iana.org/rdap/ipv4.json";
20const IANA_BOOTSTRAP_IPV6: &str = "https://data.iana.org/rdap/ipv6.json";
21const IANA_BOOTSTRAP_ASN: &str = "https://data.iana.org/rdap/asn.json";
22
23/// Default timeout for RDAP queries (30 seconds).
24/// RDAP servers can be slow, especially during bootstrap loading which fetches
25/// from 4 IANA registries. Some regional registries also have high latency.
26const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
27
28/// TTL for bootstrap data (24 hours)
29const BOOTSTRAP_TTL: Duration = Duration::from_secs(24 * 60 * 60);
30
31/// Shared HTTP client for all RDAP operations (bootstrap + queries).
32/// Reusing a single Client enables connection pooling across requests.
33static RDAP_HTTP_CLIENT: Lazy<Client> = Lazy::new(|| {
34    Client::builder()
35        .timeout(DEFAULT_TIMEOUT)
36        .user_agent("Seer/1.0 (RDAP Client)")
37        .pool_max_idle_per_host(10)
38        .build()
39        .expect("Failed to build RDAP HTTP client - invalid configuration")
40});
41
42/// Bootstrap cache with TTL support
43static BOOTSTRAP_CACHE: Lazy<RwLock<Option<CachedBootstrap>>> = Lazy::new(|| RwLock::new(None));
44
45/// Cached bootstrap data with timestamp for TTL tracking
46struct CachedBootstrap {
47    data: BootstrapData,
48    loaded_at: Instant,
49}
50
51impl CachedBootstrap {
52    fn new(data: BootstrapData) -> Self {
53        Self {
54            data,
55            loaded_at: Instant::now(),
56        }
57    }
58
59    fn is_expired(&self) -> bool {
60        self.loaded_at.elapsed() > BOOTSTRAP_TTL
61    }
62
63    fn age(&self) -> Duration {
64        self.loaded_at.elapsed()
65    }
66}
67
68/// Parsed IANA bootstrap data.
69/// Uses Arc<str> for URL strings to reduce cloning overhead when multiple
70/// TLDs/prefixes share the same RDAP server URL.
71struct BootstrapData {
72    dns: HashMap<String, Arc<str>>,
73    ipv4: Vec<(IpRange, Arc<str>)>,
74    ipv6: Vec<(IpRange, Arc<str>)>,
75    asn: Vec<(AsnRange, Arc<str>)>,
76}
77
78#[derive(Clone)]
79struct IpRange {
80    prefix: String,
81}
82
83#[derive(Clone)]
84struct AsnRange {
85    start: u32,
86    end: u32,
87}
88
89#[derive(Deserialize)]
90struct BootstrapResponse {
91    services: Vec<Vec<serde_json::Value>>,
92}
93
94#[derive(Debug, Clone)]
95pub struct RdapClient {
96    retry_policy: RetryPolicy,
97}
98
99impl Default for RdapClient {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl RdapClient {
106    /// Creates a new RDAP client with default settings.
107    pub fn new() -> Self {
108        Self {
109            retry_policy: RetryPolicy::default(),
110        }
111    }
112
113    /// Sets the retry policy for transient network failures.
114    ///
115    /// The default policy retries up to 3 times with exponential backoff.
116    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
117        self.retry_policy = policy;
118        self
119    }
120
121    /// Disables retries (single attempt only).
122    pub fn without_retries(mut self) -> Self {
123        self.retry_policy = RetryPolicy::no_retry();
124        self
125    }
126
127    /// Ensures bootstrap data is loaded and not expired.
128    /// Uses stale-while-revalidate: if refresh fails, stale data is used.
129    async fn ensure_bootstrap(&self) -> Result<()> {
130        // Check if we have valid (non-expired) data
131        {
132            let cache = BOOTSTRAP_CACHE.read().await;
133            if let Some(cached) = cache.as_ref() {
134                if !cached.is_expired() {
135                    return Ok(());
136                }
137            }
138        }
139
140        // Need to load or refresh - acquire write lock
141        let mut cache = BOOTSTRAP_CACHE.write().await;
142
143        // Double-check after acquiring write lock (another task may have loaded)
144        if let Some(cached) = cache.as_ref() {
145            if !cached.is_expired() {
146                return Ok(());
147            }
148        }
149
150        // Try to load fresh data
151        debug!("Loading/refreshing RDAP bootstrap data");
152        match load_bootstrap_data_with_retry(&self.retry_policy).await {
153            Ok(data) => {
154                debug!(
155                    dns_entries = data.dns.len(),
156                    ipv4_entries = data.ipv4.len(),
157                    ipv6_entries = data.ipv6.len(),
158                    asn_entries = data.asn.len(),
159                    "RDAP bootstrap loaded/refreshed"
160                );
161                *cache = Some(CachedBootstrap::new(data));
162                Ok(())
163            }
164            Err(e) => {
165                // Stale-while-revalidate: use stale data if refresh fails
166                if let Some(cached) = cache.as_ref() {
167                    warn!(
168                        error = %e,
169                        age_hours = cached.age().as_secs() / 3600,
170                        "Bootstrap refresh failed, using stale data"
171                    );
172                    Ok(())
173                } else {
174                    // No stale data available
175                    Err(e)
176                }
177            }
178        }
179    }
180
181    /// Looks up the RDAP server URL for a domain's TLD from bootstrap data.
182    fn get_rdap_url_for_domain(cache: &BootstrapData, domain: &str) -> Option<Arc<str>> {
183        let tld = domain.rsplit('.').next()?;
184        cache.dns.get(&tld.to_lowercase()).cloned()
185    }
186
187    /// Looks up the RDAP server URL for an IP address from bootstrap data.
188    fn get_rdap_url_for_ip(cache: &BootstrapData, ip: &IpAddr) -> Option<Arc<str>> {
189        match ip {
190            IpAddr::V4(addr) => {
191                for (range, url) in &cache.ipv4 {
192                    if ipv4_matches_prefix(&range.prefix, addr) {
193                        return Some(Arc::clone(url));
194                    }
195                }
196            }
197            IpAddr::V6(addr) => {
198                for (range, url) in &cache.ipv6 {
199                    if ipv6_matches_prefix(&range.prefix, addr) {
200                        return Some(Arc::clone(url));
201                    }
202                }
203            }
204        }
205
206        None
207    }
208
209    /// Looks up the RDAP server URL for an ASN from bootstrap data.
210    fn get_rdap_url_for_asn(cache: &BootstrapData, asn: u32) -> Option<Arc<str>> {
211        for (range, url) in &cache.asn {
212            if asn >= range.start && asn <= range.end {
213                return Some(Arc::clone(url));
214            }
215        }
216
217        None
218    }
219
220    /// Looks up RDAP registration data for a domain.
221    ///
222    /// Uses IANA bootstrap data to find the appropriate RDAP server for the TLD.
223    #[instrument(skip(self), fields(domain = %domain))]
224    pub async fn lookup_domain(&self, domain: &str) -> Result<RdapResponse> {
225        self.ensure_bootstrap().await?;
226
227        let domain = normalize_domain(domain)?;
228
229        // Extract URL while holding the lock, then release before HTTP request
230        let url = {
231            let cache_guard = BOOTSTRAP_CACHE.read().await;
232            let cache = cache_guard.as_ref().ok_or_else(|| {
233                SeerError::RdapBootstrapError("bootstrap data not loaded".to_string())
234            })?;
235
236            let base_url =
237                Self::get_rdap_url_for_domain(&cache.data, &domain).ok_or_else(|| {
238                    SeerError::RdapBootstrapError(format!("no RDAP server for {}", domain))
239                })?;
240
241            build_rdap_url(&base_url, &format!("domain/{}", domain))
242        }; // Lock released here
243
244        debug!(url = %url, "Querying RDAP");
245        self.query_rdap_with_retry(&url).await
246    }
247
248    /// Looks up RDAP registration data for an IP address.
249    ///
250    /// Uses IANA bootstrap data to find the appropriate RIR (Regional Internet Registry).
251    #[instrument(skip(self), fields(ip = %ip))]
252    pub async fn lookup_ip(&self, ip: &str) -> Result<RdapResponse> {
253        self.ensure_bootstrap().await?;
254
255        let ip_addr: IpAddr = ip
256            .parse()
257            .map_err(|_| SeerError::InvalidIpAddress(ip.to_string()))?;
258
259        // Extract URL while holding the lock, then release before HTTP request
260        let url = {
261            let cache_guard = BOOTSTRAP_CACHE.read().await;
262            let cache = cache_guard.as_ref().ok_or_else(|| {
263                SeerError::RdapBootstrapError("bootstrap data not loaded".to_string())
264            })?;
265
266            let base_url = Self::get_rdap_url_for_ip(&cache.data, &ip_addr).ok_or_else(|| {
267                SeerError::RdapBootstrapError(format!("no RDAP server for {}", ip))
268            })?;
269
270            build_rdap_url(&base_url, &format!("ip/{}", ip))
271        }; // Lock released here
272
273        debug!(url = %url, "Querying RDAP");
274        self.query_rdap_with_retry(&url).await
275    }
276
277    /// Looks up RDAP registration data for an Autonomous System Number (ASN).
278    ///
279    /// Uses IANA bootstrap data to find the appropriate RIR for the ASN range.
280    #[instrument(skip(self), fields(asn = %asn))]
281    pub async fn lookup_asn(&self, asn: u32) -> Result<RdapResponse> {
282        self.ensure_bootstrap().await?;
283
284        // Extract URL while holding the lock, then release before HTTP request
285        let url = {
286            let cache_guard = BOOTSTRAP_CACHE.read().await;
287            let cache = cache_guard.as_ref().ok_or_else(|| {
288                SeerError::RdapBootstrapError("bootstrap data not loaded".to_string())
289            })?;
290
291            let base_url = Self::get_rdap_url_for_asn(&cache.data, asn).ok_or_else(|| {
292                SeerError::RdapBootstrapError(format!("no RDAP server for AS{}", asn))
293            })?;
294
295            build_rdap_url(&base_url, &format!("autnum/{}", asn))
296        }; // Lock released here
297
298        debug!(url = %url, "Querying RDAP");
299        self.query_rdap_with_retry(&url).await
300    }
301
302    /// Returns the RDAP base URL for a given TLD, if known from bootstrap data.
303    ///
304    /// Loads bootstrap data if not already cached. Returns `None` if the TLD
305    /// has no registered RDAP server in the IANA bootstrap registry.
306    #[instrument(skip(self), fields(tld = %tld))]
307    pub async fn get_rdap_base_url_for_tld(&self, tld: &str) -> Option<String> {
308        if self.ensure_bootstrap().await.is_err() {
309            return None;
310        }
311
312        let cache_guard = BOOTSTRAP_CACHE.read().await;
313        let cache = cache_guard.as_ref()?;
314        cache
315            .data
316            .dns
317            .get(&tld.to_lowercase())
318            .map(|url| url.to_string())
319    }
320
321    /// Queries an RDAP endpoint with retry logic.
322    async fn query_rdap_with_retry(&self, url: &str) -> Result<RdapResponse> {
323        let executor = RetryExecutor::new(self.retry_policy.clone());
324        let url = url.to_string();
325
326        executor
327            .execute(|| {
328                let http = RDAP_HTTP_CLIENT.clone();
329                let url = url.clone();
330                async move { query_rdap_internal(&http, &url).await }
331            })
332            .await
333    }
334}
335
336/// Maximum RDAP response body size (10 MB, matching CT log response limit).
337const MAX_RDAP_RESPONSE_SIZE: usize = 10 * 1024 * 1024;
338
339/// Validates that a URL does not resolve to a reserved/private IP address (SSRF protection).
340async fn validate_url_not_reserved(url: &str) -> Result<()> {
341    let parsed = url::Url::parse(url)
342        .map_err(|e| SeerError::RdapError(format!("invalid URL '{}': {}", url, e)))?;
343    let host = parsed
344        .host_str()
345        .ok_or_else(|| SeerError::RdapError(format!("URL '{}' has no host", url)))?;
346
347    // If the host is already an IP literal, check it directly
348    if let Ok(ip) = host.parse::<IpAddr>() {
349        if let Some(reason) = describe_reserved_ip(&ip) {
350            return Err(SeerError::RdapError(format!(
351                "RDAP URL resolves to reserved IP {}: {} — request blocked (SSRF protection)",
352                ip, reason
353            )));
354        }
355        return Ok(());
356    }
357
358    let port = parsed.port_or_known_default().unwrap_or(443);
359    let addr = format!("{}:{}", host, port);
360
361    let socket_addrs: Vec<_> = tokio::net::lookup_host(&addr)
362        .await
363        .map_err(|e| SeerError::RdapError(format!("failed to resolve host '{}': {}", host, e)))?
364        .collect();
365
366    if socket_addrs.is_empty() {
367        return Err(SeerError::RdapError(format!(
368            "host '{}' resolved to no addresses",
369            host
370        )));
371    }
372
373    for socket_addr in &socket_addrs {
374        if let Some(reason) = describe_reserved_ip(&socket_addr.ip()) {
375            return Err(SeerError::RdapError(format!(
376                "RDAP URL resolves to reserved IP {}: {} — request blocked (SSRF protection)",
377                socket_addr.ip(),
378                reason
379            )));
380        }
381    }
382
383    Ok(())
384}
385
386/// Internal function to query an RDAP endpoint (used by retry executor).
387async fn query_rdap_internal(http: &Client, url: &str) -> Result<RdapResponse> {
388    // SSRF protection: validate the URL does not resolve to a reserved/private IP
389    validate_url_not_reserved(url).await?;
390
391    let response = http
392        .get(url)
393        .header("Accept", "application/rdap+json")
394        .send()
395        .await?;
396
397    if !response.status().is_success() {
398        return Err(SeerError::RdapError(format!(
399            "query failed with status {}",
400            response.status()
401        )));
402    }
403
404    // Stream body with incremental size check to prevent memory exhaustion
405    let mut body = Vec::new();
406    let mut stream = response.bytes_stream();
407    while let Some(chunk) = stream.next().await {
408        let chunk =
409            chunk.map_err(|e| SeerError::RdapError(format!("failed to read response: {}", e)))?;
410        body.extend_from_slice(&chunk);
411        if body.len() > MAX_RDAP_RESPONSE_SIZE {
412            return Err(SeerError::RdapError(format!(
413                "RDAP response exceeds {} byte limit",
414                MAX_RDAP_RESPONSE_SIZE
415            )));
416        }
417    }
418    let rdap: RdapResponse = serde_json::from_slice(&body)?;
419    Ok(rdap)
420}
421
422/// Loads IANA RDAP bootstrap data from all registries with retry.
423async fn load_bootstrap_data_with_retry(policy: &RetryPolicy) -> Result<BootstrapData> {
424    let executor = RetryExecutor::new(policy.clone());
425    executor.execute(load_bootstrap_data).await
426}
427
428/// Loads IANA RDAP bootstrap data from all registries.
429async fn load_bootstrap_data() -> Result<BootstrapData> {
430    debug!("Loading RDAP bootstrap data from IANA");
431
432    // Defense-in-depth: validate IANA bootstrap URLs don't resolve to reserved IPs
433    let bootstrap_urls = [
434        IANA_BOOTSTRAP_DNS,
435        IANA_BOOTSTRAP_IPV4,
436        IANA_BOOTSTRAP_IPV6,
437        IANA_BOOTSTRAP_ASN,
438    ];
439    for url in &bootstrap_urls {
440        validate_url_not_reserved(url).await?;
441    }
442
443    let http = &*RDAP_HTTP_CLIENT;
444
445    let dns_future = http.get(IANA_BOOTSTRAP_DNS).send();
446    let ipv4_future = http.get(IANA_BOOTSTRAP_IPV4).send();
447    let ipv6_future = http.get(IANA_BOOTSTRAP_IPV6).send();
448    let asn_future = http.get(IANA_BOOTSTRAP_ASN).send();
449
450    let (dns_resp, ipv4_resp, ipv6_resp, asn_resp) =
451        tokio::try_join!(dns_future, ipv4_future, ipv6_future, asn_future)?;
452
453    // Read bootstrap responses with incremental size check to prevent memory exhaustion
454    const MAX_BOOTSTRAP_SIZE: usize = 10 * 1024 * 1024; // 10 MB
455
456    async fn read_bootstrap(resp: reqwest::Response) -> Result<BootstrapResponse> {
457        let mut body = Vec::new();
458        let mut stream = resp.bytes_stream();
459        while let Some(chunk) = stream.next().await {
460            let chunk = chunk.map_err(|e| {
461                SeerError::RdapBootstrapError(format!("failed to read body: {}", e))
462            })?;
463            body.extend_from_slice(&chunk);
464            if body.len() > MAX_BOOTSTRAP_SIZE {
465                return Err(SeerError::RdapBootstrapError(format!(
466                    "bootstrap response too large (exceeds {} bytes)",
467                    MAX_BOOTSTRAP_SIZE
468                )));
469            }
470        }
471        serde_json::from_slice(&body).map_err(Into::into)
472    }
473
474    let dns_data = read_bootstrap(dns_resp).await?;
475    let ipv4_data = read_bootstrap(ipv4_resp).await?;
476    let ipv6_data = read_bootstrap(ipv6_resp).await?;
477    let asn_data = read_bootstrap(asn_resp).await?;
478
479    let mut dns = HashMap::new();
480    let mut ipv4 = Vec::new();
481    let mut ipv6 = Vec::new();
482    let mut asn = Vec::new();
483
484    // Parse DNS bootstrap
485    for service in dns_data.services {
486        if service.len() >= 2 {
487            if let (Some(tlds), Some(urls)) = (service[0].as_array(), service[1].as_array()) {
488                if let Some(url) = urls.first().and_then(|u| u.as_str()) {
489                    let url_arc: Arc<str> = Arc::from(url);
490                    for tld in tlds {
491                        if let Some(tld_str) = tld.as_str() {
492                            dns.insert(tld_str.to_lowercase(), Arc::clone(&url_arc));
493                        }
494                    }
495                }
496            }
497        }
498    }
499
500    // Parse IPv4 bootstrap
501    for service in ipv4_data.services {
502        if service.len() >= 2 {
503            if let (Some(prefixes), Some(urls)) = (service[0].as_array(), service[1].as_array()) {
504                if let Some(url) = urls.first().and_then(|u| u.as_str()) {
505                    let url_arc: Arc<str> = Arc::from(url);
506                    for prefix in prefixes {
507                        if let Some(prefix_str) = prefix.as_str() {
508                            ipv4.push((
509                                IpRange {
510                                    prefix: prefix_str.to_string(),
511                                },
512                                Arc::clone(&url_arc),
513                            ));
514                        }
515                    }
516                }
517            }
518        }
519    }
520
521    // Parse IPv6 bootstrap
522    for service in ipv6_data.services {
523        if service.len() >= 2 {
524            if let (Some(prefixes), Some(urls)) = (service[0].as_array(), service[1].as_array()) {
525                if let Some(url) = urls.first().and_then(|u| u.as_str()) {
526                    let url_arc: Arc<str> = Arc::from(url);
527                    for prefix in prefixes {
528                        if let Some(prefix_str) = prefix.as_str() {
529                            ipv6.push((
530                                IpRange {
531                                    prefix: prefix_str.to_string(),
532                                },
533                                Arc::clone(&url_arc),
534                            ));
535                        }
536                    }
537                }
538            }
539        }
540    }
541
542    // Parse ASN bootstrap
543    for service in asn_data.services {
544        if service.len() >= 2 {
545            if let (Some(ranges), Some(urls)) = (service[0].as_array(), service[1].as_array()) {
546                if let Some(url) = urls.first().and_then(|u| u.as_str()) {
547                    let url_arc: Arc<str> = Arc::from(url);
548                    for range in ranges {
549                        if let Some(range_str) = range.as_str() {
550                            if let Some((start, end)) = parse_asn_range(range_str) {
551                                asn.push((AsnRange { start, end }, Arc::clone(&url_arc)));
552                            }
553                        }
554                    }
555                }
556            }
557        }
558    }
559
560    Ok(BootstrapData {
561        dns,
562        ipv4,
563        ipv6,
564        asn,
565    })
566}
567
568/// Builds a full RDAP query URL from a base URL and path.
569fn build_rdap_url(base_url: &str, path: &str) -> String {
570    if base_url.ends_with('/') {
571        format!("{}{}", base_url, path)
572    } else {
573        format!("{}/{}", base_url, path)
574    }
575}
576
577fn parse_asn_range(range: &str) -> Option<(u32, u32)> {
578    if let Some(pos) = range.find('-') {
579        let start = range[..pos].parse().ok()?;
580        let end = range[pos + 1..].parse().ok()?;
581        Some((start, end))
582    } else {
583        let num = range.parse().ok()?;
584        Some((num, num))
585    }
586}
587
588fn ipv4_matches_prefix(prefix: &str, ip: &Ipv4Addr) -> bool {
589    let (addr_part, mask_part) = match prefix.split_once('/') {
590        Some((a, m)) => (a, Some(m)),
591        None => (prefix, None),
592    };
593
594    let prefix_ip: Ipv4Addr = match addr_part.parse() {
595        Ok(ip) => ip,
596        Err(_) => return false,
597    };
598
599    let mask_bits: u32 = match mask_part.and_then(|s| s.parse().ok()) {
600        Some(bits) if bits <= 32 => bits,
601        Some(_) => return false,
602        None => 32,
603    };
604
605    let mask = if mask_bits == 0 {
606        0
607    } else {
608        u32::MAX << (32 - mask_bits)
609    };
610
611    let ip_value = u32::from(*ip);
612    let prefix_value = u32::from(prefix_ip);
613
614    (ip_value & mask) == (prefix_value & mask)
615}
616
617fn ipv6_matches_prefix(prefix: &str, ip: &Ipv6Addr) -> bool {
618    let (addr_part, mask_part) = match prefix.split_once('/') {
619        Some((a, m)) => (a, Some(m)),
620        None => (prefix, None),
621    };
622
623    let prefix_ip: Ipv6Addr = match addr_part.parse() {
624        Ok(ip) => ip,
625        Err(_) => return false,
626    };
627
628    let mask_bits: u32 = match mask_part.and_then(|s| s.parse().ok()) {
629        Some(bits) if bits <= 128 => bits,
630        Some(_) => return false,
631        None => 128,
632    };
633
634    let mask = if mask_bits == 0 {
635        0u128
636    } else {
637        u128::MAX << (128 - mask_bits)
638    };
639
640    let ip_value = ipv6_to_u128(ip);
641    let prefix_value = ipv6_to_u128(&prefix_ip);
642
643    (ip_value & mask) == (prefix_value & mask)
644}
645
646fn ipv6_to_u128(ip: &Ipv6Addr) -> u128 {
647    let segments = ip.segments();
648    let mut value = 0u128;
649    for segment in segments {
650        value = (value << 16) | segment as u128;
651    }
652    value
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    #[test]
660    fn test_default_client_has_retry_policy() {
661        let client = RdapClient::new();
662        assert_eq!(client.retry_policy.max_attempts, 3);
663    }
664
665    #[test]
666    fn test_client_without_retries() {
667        let client = RdapClient::new().without_retries();
668        assert_eq!(client.retry_policy.max_attempts, 1);
669    }
670
671    #[test]
672    fn test_client_custom_retry_policy() {
673        let policy = RetryPolicy::new().with_max_attempts(5);
674        let client = RdapClient::new().with_retry_policy(policy);
675        assert_eq!(client.retry_policy.max_attempts, 5);
676    }
677
678    #[test]
679    fn test_cached_bootstrap_expiration() {
680        let data = BootstrapData {
681            dns: HashMap::new(),
682            ipv4: Vec::new(),
683            ipv6: Vec::new(),
684            asn: Vec::new(),
685        };
686        let cached = CachedBootstrap::new(data);
687        // Fresh cache should not be expired
688        assert!(!cached.is_expired());
689    }
690
691    #[test]
692    fn test_ipv4_prefix_matching_partial_mask() {
693        let ip_in = Ipv4Addr::new(203, 0, 114, 1);
694        let ip_out = Ipv4Addr::new(203, 0, 120, 1);
695        assert!(ipv4_matches_prefix("203.0.112.0/21", &ip_in));
696        assert!(!ipv4_matches_prefix("203.0.112.0/21", &ip_out));
697    }
698
699    #[test]
700    fn test_ipv6_prefix_matching_partial_mask() {
701        let ip_in: Ipv6Addr = "2001:db8::1".parse().unwrap();
702        let ip_out: Ipv6Addr = "2001:db9::1".parse().unwrap();
703        assert!(ipv6_matches_prefix("2001:db8::/33", &ip_in));
704        assert!(!ipv6_matches_prefix("2001:db8::/33", &ip_out));
705    }
706}