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