use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use reqwest::Client;
use serde::Deserialize;
use std::sync::LazyLock;
use tokio::sync::{Notify, RwLock};
use tracing::{debug, info, instrument, warn};
use super::bootstrap::{ip_matches_prefix, parse_asn_range, validate_bootstrap_url};
use super::types::RdapResponse;
use crate::error::{Result, SeerError};
use crate::http::{read_body_capped, BodyReadError, Overflow};
use crate::retry::{NetworkRetryClassifier, RetryClassifier, RetryExecutor, RetryPolicy};
use crate::validation::normalize_domain;
const IANA_BOOTSTRAP_DNS: &str = "https://data.iana.org/rdap/dns.json";
const IANA_BOOTSTRAP_IPV4: &str = "https://data.iana.org/rdap/ipv4.json";
const IANA_BOOTSTRAP_IPV6: &str = "https://data.iana.org/rdap/ipv6.json";
const IANA_BOOTSTRAP_ASN: &str = "https://data.iana.org/rdap/asn.json";
pub(crate) const MAX_BOOTSTRAP_SIZE: usize = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const BOOTSTRAP_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const BOOTSTRAP_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(60);
const BOOTSTRAP_PARTIAL_TTL: Duration = Duration::from_secs(5 * 60);
const RDAP_ACCEPT: &str = "application/rdap+json, application/json;q=0.9";
const MAX_RDAP_REDIRECTS: usize = 3;
const RDAP_USER_AGENT: &str = "Seer/1.0 (RDAP Client)";
static RDAP_HTTP_CLIENT: LazyLock<Option<Client>> = LazyLock::new(|| {
rdap_client_builder(DEFAULT_TIMEOUT)
.pool_max_idle_per_host(10)
.build()
.ok()
});
fn rdap_http_client() -> Result<&'static Client> {
RDAP_HTTP_CLIENT
.as_ref()
.ok_or_else(|| SeerError::HttpError("failed to initialize HTTP client".into()))
}
static BOOTSTRAP_CACHE: LazyLock<RwLock<Option<CachedBootstrap>>> =
LazyLock::new(|| RwLock::new(None));
static BOOTSTRAP_LAST_ATTEMPT: LazyLock<RwLock<Option<Instant>>> =
LazyLock::new(|| RwLock::new(None));
static BOOTSTRAP_LOAD_NOTIFY: LazyLock<Notify> = LazyLock::new(Notify::new);
static BOOTSTRAP_LOAD_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
struct BootstrapLoadGuard;
impl BootstrapLoadGuard {
fn start() -> Self {
BOOTSTRAP_LOAD_IN_FLIGHT.store(true, Ordering::SeqCst);
Self
}
}
impl Drop for BootstrapLoadGuard {
fn drop(&mut self) {
BOOTSTRAP_LOAD_IN_FLIGHT.store(false, Ordering::SeqCst);
BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
}
}
struct CachedBootstrap {
data: BootstrapData,
loaded_at: Instant,
ttl: Duration,
}
impl CachedBootstrap {
#[cfg(test)]
fn new(data: BootstrapData) -> Self {
Self::with_ttl(data, BOOTSTRAP_TTL)
}
fn with_ttl(data: BootstrapData, ttl: Duration) -> Self {
Self {
data,
loaded_at: Instant::now(),
ttl,
}
}
fn is_expired(&self) -> bool {
self.loaded_at.elapsed() > self.ttl
}
fn age(&self) -> Duration {
self.loaded_at.elapsed()
}
}
struct BootstrapData {
dns: HashMap<String, Arc<Vec<url::Url>>>,
ipv4: Vec<(IpRange, Arc<Vec<url::Url>>)>,
ipv6: Vec<(IpRange, Arc<Vec<url::Url>>)>,
asn: Vec<(AsnRange, Arc<Vec<url::Url>>)>,
}
#[derive(Clone)]
struct IpRange {
prefix: String,
}
#[derive(Clone)]
struct AsnRange {
start: u32,
end: u32,
}
#[derive(Deserialize)]
struct BootstrapResponse {
services: Vec<Vec<serde_json::Value>>,
}
struct BootstrapLoad {
dns: Option<HashMap<String, Arc<Vec<url::Url>>>>,
ipv4: Option<Vec<(IpRange, Arc<Vec<url::Url>>)>>,
ipv6: Option<Vec<(IpRange, Arc<Vec<url::Url>>)>>,
asn: Option<Vec<(AsnRange, Arc<Vec<url::Url>>)>>,
}
impl BootstrapLoad {
fn is_complete(&self) -> bool {
self.dns.is_some() && self.ipv4.is_some() && self.ipv6.is_some() && self.asn.is_some()
}
fn ttl(&self) -> Duration {
if self.is_complete() {
BOOTSTRAP_TTL
} else {
BOOTSTRAP_PARTIAL_TTL
}
}
fn merge_over(self, previous: Option<BootstrapData>) -> BootstrapData {
let (dns, ipv4, ipv6, asn) = match previous {
Some(p) => (Some(p.dns), Some(p.ipv4), Some(p.ipv6), Some(p.asn)),
None => (None, None, None, None),
};
BootstrapData {
dns: self.dns.or(dns).unwrap_or_default(),
ipv4: self.ipv4.or(ipv4).unwrap_or_default(),
ipv6: self.ipv6.or(ipv6).unwrap_or_default(),
asn: self.asn.or(asn).unwrap_or_default(),
}
}
}
fn store_bootstrap_load(cache: &mut Option<CachedBootstrap>, load: BootstrapLoad) {
if cache.as_ref().is_some_and(|c| !c.is_expired()) {
return;
}
let ttl = load.ttl();
if !load.is_complete() {
warn!(
dns = load.dns.is_some(),
ipv4 = load.ipv4.is_some(),
ipv6 = load.ipv6.is_some(),
asn = load.asn.is_some(),
retry_in_secs = ttl.as_secs(),
"RDAP bootstrap partially loaded; keeping previous data for failed registries"
);
}
let previous = cache.take().map(|c| c.data);
*cache = Some(CachedBootstrap::with_ttl(load.merge_over(previous), ttl));
}
async fn wait_for_in_flight_load(
notified: std::pin::Pin<&mut tokio::sync::futures::Notified<'_>>,
) -> Result<()> {
if BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst) {
let _ = tokio::time::timeout(DEFAULT_TIMEOUT, notified).await;
}
let cache = BOOTSTRAP_CACHE.read().await;
if cache.is_some() {
Ok(())
} else {
Err(SeerError::RdapBootstrapError(
"bootstrap refresh throttled and no cache available".to_string(),
))
}
}
#[derive(Debug, Clone)]
pub struct RdapClient {
retry_policy: RetryPolicy,
timeout: Duration,
allow_reserved: bool,
}
impl Default for RdapClient {
fn default() -> Self {
Self::new()
}
}
impl RdapClient {
pub fn new() -> Self {
Self {
retry_policy: RetryPolicy::new()
.with_max_attempts(3)
.with_initial_delay(Duration::from_millis(500))
.with_max_delay(Duration::from_secs(5)),
timeout: DEFAULT_TIMEOUT,
allow_reserved: false,
}
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self::new().with_timeout(config.rdap_timeout())
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[cfg(test)]
pub(crate) fn allowing_reserved_for_tests(mut self) -> Self {
self.allow_reserved = true;
self
}
pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
self.retry_policy = policy;
self
}
pub fn without_retries(mut self) -> Self {
self.retry_policy = RetryPolicy::no_retry();
self
}
async fn ensure_bootstrap(&self) -> Result<()> {
{
let cache = BOOTSTRAP_CACHE.read().await;
if let Some(cached) = cache.as_ref() {
if !cached.is_expired() {
return Ok(());
}
}
}
let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
tokio::pin!(notified);
{
let last = BOOTSTRAP_LAST_ATTEMPT.read().await;
if let Some(ts) = *last {
if ts.elapsed() < BOOTSTRAP_REFRESH_MIN_INTERVAL {
let cache = BOOTSTRAP_CACHE.read().await;
if cache.is_some() {
return Ok(());
}
drop(cache);
drop(last);
return wait_for_in_flight_load(notified).await;
}
}
}
let load_guard = {
let mut last = BOOTSTRAP_LAST_ATTEMPT.write().await;
if let Some(ts) = *last {
if ts.elapsed() < BOOTSTRAP_REFRESH_MIN_INTERVAL {
drop(last);
let cache = BOOTSTRAP_CACHE.read().await;
if cache.is_some() {
return Ok(());
}
drop(cache);
return wait_for_in_flight_load(notified).await;
}
}
*last = Some(Instant::now());
BootstrapLoadGuard::start()
};
debug!("Loading/refreshing RDAP bootstrap data");
let load_result = load_bootstrap_data_with_retry(&self.retry_policy).await;
let outcome = match load_result {
Ok(load) => {
let mut cache = BOOTSTRAP_CACHE.write().await;
store_bootstrap_load(&mut cache, load);
Ok(())
}
Err(e) => {
let cache = BOOTSTRAP_CACHE.read().await;
if let Some(cached) = cache.as_ref() {
debug!(
error = %e,
age_hours = cached.age().as_secs() / 3600,
"Bootstrap refresh failed, using stale data"
);
Ok(())
} else {
Err(e)
}
}
};
drop(load_guard);
outcome
}
fn get_rdap_urls_for_domain(cache: &BootstrapData, domain: &str) -> Option<Arc<Vec<url::Url>>> {
let tld = domain.rsplit('.').next()?;
cache.dns.get(&tld.to_lowercase()).cloned()
}
fn get_rdap_urls_for_ip(cache: &BootstrapData, ip: IpAddr) -> Option<Arc<Vec<url::Url>>> {
let ranges = match ip {
IpAddr::V4(_) => &cache.ipv4,
IpAddr::V6(_) => &cache.ipv6,
};
ranges
.iter()
.find(|(range, _)| ip_matches_prefix(&range.prefix, ip))
.map(|(_, urls)| Arc::clone(urls))
}
fn get_rdap_urls_for_asn(cache: &BootstrapData, asn: u32) -> Option<Arc<Vec<url::Url>>> {
cache
.asn
.iter()
.find(|(range, _)| (range.start..=range.end).contains(&asn))
.map(|(_, urls)| Arc::clone(urls))
}
async fn candidate_urls(
pick: impl FnOnce(&BootstrapData) -> Option<Arc<Vec<url::Url>>>,
what: &str,
path: &str,
) -> Result<Vec<url::Url>> {
let cache_guard = BOOTSTRAP_CACHE.read().await;
let cache = cache_guard.as_ref().ok_or_else(|| {
SeerError::RdapBootstrapError("bootstrap data not loaded".to_string())
})?;
let bases = pick(&cache.data)
.ok_or_else(|| SeerError::RdapBootstrapError(format!("no RDAP server for {}", what)))?;
Ok(build_rdap_urls(&bases, path))
}
#[instrument(skip(self), fields(domain = %domain))]
pub async fn lookup_domain(&self, domain: &str) -> Result<RdapResponse> {
self.ensure_bootstrap().await?;
let domain = normalize_domain(domain)?;
let urls = Self::candidate_urls(
|data| Self::get_rdap_urls_for_domain(data, &domain),
&domain,
&format!("domain/{}", domain),
)
.await?;
self.query_rdap_urls(&urls).await
}
#[instrument(skip(self), fields(ip = %ip))]
pub async fn lookup_ip(&self, ip: &str) -> Result<RdapResponse> {
self.ensure_bootstrap().await?;
let ip_addr: IpAddr = ip
.parse()
.map_err(|_| SeerError::InvalidIpAddress(ip.to_string()))?;
let urls = Self::candidate_urls(
|data| Self::get_rdap_urls_for_ip(data, ip_addr),
ip,
&format!("ip/{}", ip),
)
.await?;
self.query_rdap_urls(&urls).await
}
#[instrument(skip(self), fields(asn = %asn))]
pub async fn lookup_asn(&self, asn: u32) -> Result<RdapResponse> {
self.ensure_bootstrap().await?;
let urls = Self::candidate_urls(
|data| Self::get_rdap_urls_for_asn(data, asn),
&format!("AS{}", asn),
&format!("autnum/{}", asn),
)
.await?;
self.query_rdap_urls(&urls).await
}
#[instrument(skip(self), fields(tld = %tld))]
pub async fn get_rdap_base_url_for_tld(&self, tld: &str) -> Option<String> {
if self.ensure_bootstrap().await.is_err() {
return None;
}
let cache_guard = BOOTSTRAP_CACHE.read().await;
let cache = cache_guard.as_ref()?;
let lower = tld.to_lowercase();
let key = crate::validation::domain_to_ascii(&lower).unwrap_or(lower);
cache
.data
.dns
.get(&key)
.and_then(|urls| urls.first())
.map(|u| u.to_string())
}
async fn query_rdap_urls(&self, urls: &[url::Url]) -> Result<RdapResponse> {
if urls.is_empty() {
return Err(SeerError::RdapError(
"no candidate RDAP URLs available".to_string(),
));
}
let mut last_error: Option<SeerError> = None;
let mut not_found_error: Option<SeerError> = None;
for (idx, url) in urls.iter().enumerate() {
let url_str = url.as_str().to_string();
debug!(url = %url_str, candidate = idx + 1, total = urls.len(), "Querying RDAP");
match self.query_rdap_with_retry(&url_str).await {
Ok(resp) => return Ok(resp),
Err(e) => {
if urls.len() > 1 {
debug!(
url = %url_str,
error = %e,
candidate = idx + 1,
total = urls.len(),
"RDAP candidate failed, trying next",
);
}
if not_found_error.is_none() && crate::rdap::rdap_error_is_404(&e) {
not_found_error = Some(e);
} else {
last_error = Some(e);
}
}
}
}
Err(wrap_all_candidates_failed(
not_found_error.or(last_error),
urls.len(),
))
}
async fn query_rdap_with_retry(&self, url: &str) -> Result<RdapResponse> {
let classifier = NetworkRetryClassifier::new();
let mut attempt = 0;
loop {
match query_rdap_attempt(url, self.timeout, self.allow_reserved).await {
Ok(resp) => return Ok(resp),
Err((err, retry_after)) => {
let attempts_remaining =
self.retry_policy.max_attempts.saturating_sub(attempt + 1);
if !classifier.is_retryable(&err) || attempts_remaining == 0 {
return Err(if attempt > 0 {
SeerError::RetryExhausted {
attempts: attempt + 1,
last_error: Box::new(err),
}
} else {
err
});
}
let backoff = self.retry_policy.delay_for_attempt(attempt);
let delay = effective_retry_delay(backoff, retry_after);
debug!(
url = %url,
attempt = attempt + 1,
max_attempts = self.retry_policy.max_attempts,
delay_ms = delay.as_millis(),
error = %err,
"Retrying RDAP after transient error"
);
tokio::time::sleep(delay).await;
attempt += 1;
}
}
}
}
}
const MAX_RDAP_RESPONSE_SIZE: usize = 10 * 1024 * 1024;
const MAX_RETRY_AFTER: Duration = Duration::from_secs(5);
fn parse_rdap_url(url: &str) -> Result<(String, u16)> {
let parsed = url::Url::parse(url)
.map_err(|e| SeerError::RdapError(format!("invalid URL '{}': {}", url, e)))?;
if parsed.scheme() != "https" {
return Err(SeerError::RdapError(format!(
"RDAP URL '{}' is not https — request blocked (downgrade/SSRF protection)",
url
)));
}
let host = crate::net::url_host(&parsed)
.ok_or_else(|| SeerError::RdapError(format!("URL '{}' has no host", url)))?;
let port = parsed.port_or_known_default().unwrap_or(443);
Ok((host, port))
}
async fn resolve_rdap_host(host: &str, port: u16) -> Result<Vec<SocketAddr>> {
crate::net::resolve_public_host(host, port)
.await
.map_err(|e| SeerError::RdapError(format!("{} — request blocked (SSRF protection)", e)))
}
#[cfg(test)]
async fn validate_url_not_reserved(url: &str) -> Result<Vec<SocketAddr>> {
let (host, port) = parse_rdap_url(url)?;
resolve_rdap_host(&host, port).await
}
fn parse_retry_after(value: &str) -> Option<Duration> {
value.trim().parse::<u64>().ok().map(Duration::from_secs)
}
fn effective_retry_delay(backoff: Duration, retry_after: Option<Duration>) -> Duration {
match retry_after {
Some(hint) => hint.min(MAX_RETRY_AFTER),
None => backoff,
}
}
const PINNED_CLIENT_TTL: Duration = Duration::from_secs(60);
type PinnedClientKey = (String, u16, Duration);
static PINNED_CLIENT_CACHE: LazyLock<crate::cache::TtlCache<PinnedClientKey, Client>> =
LazyLock::new(|| crate::cache::TtlCache::with_max_capacity(PINNED_CLIENT_TTL, 64));
fn rdap_client_builder(timeout: Duration) -> reqwest::ClientBuilder {
crate::net::client_builder(timeout)
.connect_timeout(CONNECT_TIMEOUT.min(timeout))
.user_agent(RDAP_USER_AGENT)
}
async fn send_rdap_request(
url: &str,
timeout: Duration,
allow_reserved: bool,
) -> Result<reqwest::Response> {
if allow_reserved && is_loopback_url(url) {
let client = rdap_client_builder(timeout)
.build()
.map_err(|e| SeerError::RdapError(format!("failed to build HTTP client: {}", e)))?;
return client
.get(url)
.header(reqwest::header::ACCEPT, RDAP_ACCEPT)
.send()
.await
.map_err(Into::into);
}
let (host, port) = parse_rdap_url(url)?;
let key = (host.clone(), port, timeout);
let client = match PINNED_CLIENT_CACHE.get(&key) {
Some(client) => client,
None => {
let resolved = resolve_rdap_host(&host, port).await?;
let client = rdap_client_builder(timeout)
.resolve_to_addrs(&host, &resolved)
.build()
.map_err(|e| SeerError::RdapError(format!("failed to build HTTP client: {}", e)))?;
PINNED_CLIENT_CACHE.insert(key.clone(), client.clone());
client
}
};
match client
.get(url)
.header(reqwest::header::ACCEPT, RDAP_ACCEPT)
.send()
.await
{
Ok(resp) => Ok(resp),
Err(e) => {
if e.is_connect() {
PINNED_CLIENT_CACHE.remove(&key);
}
Err(e.into())
}
}
}
async fn read_and_parse_rdap_body(
response: reqwest::Response,
url: &str,
timeout: Duration,
) -> Result<RdapResponse> {
let body = read_body_capped(response, MAX_RDAP_RESPONSE_SIZE, timeout, Overflow::Reject)
.await
.map_err(|e| match e {
BodyReadError::Chunk(e) => {
SeerError::RdapError(format!("failed to read response: {}", e))
}
BodyReadError::TooLarge => SeerError::RdapError(format!(
"RDAP response exceeds {} byte limit",
MAX_RDAP_RESPONSE_SIZE
)),
BodyReadError::TimedOut => SeerError::Timeout(format!(
"timed out reading RDAP response body from {} after {:?}",
url, timeout
)),
})?;
let rdap: RdapResponse = serde_json::from_slice(&body)?;
rdap.validate()?;
Ok(rdap)
}
fn is_loopback_url(url: &str) -> bool {
match url::Url::parse(url).ok().as_ref().and_then(url::Url::host) {
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
_ => false,
}
}
fn redirect_location(response: &reqwest::Response) -> Option<&str> {
match response.status().as_u16() {
301 | 302 | 303 | 307 | 308 => response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok()),
_ => None,
}
}
fn resolve_redirect_target(current: &str, location: &str) -> Result<String> {
url::Url::parse(current)
.and_then(|base| base.join(location))
.map(String::from)
.map_err(|e| SeerError::RdapError(format!("invalid RDAP redirect target: {}", e)))
}
async fn query_rdap_attempt(
url: &str,
timeout: Duration,
allow_reserved: bool,
) -> std::result::Result<RdapResponse, (SeerError, Option<Duration>)> {
let mut current = url.to_string();
let mut visited: HashSet<String> = HashSet::new();
let response = loop {
visited.insert(current.clone());
let response = send_rdap_request(¤t, timeout, allow_reserved)
.await
.map_err(|e| (e, None))?;
let Some(location) = redirect_location(&response) else {
break response;
};
if visited.len() > MAX_RDAP_REDIRECTS {
return Err((
SeerError::RdapError(format!(
"RDAP query exceeded {} redirects",
MAX_RDAP_REDIRECTS
)),
None,
));
}
let next = resolve_redirect_target(¤t, location).map_err(|e| (e, None))?;
if visited.contains(&next) {
return Err((
SeerError::RdapError("RDAP redirect loop detected".to_string()),
None,
));
}
debug!(from = %current, to = %next, "Following RDAP redirect");
current = next;
};
if !response.status().is_success() {
let status = response.status();
let retry_after = if status.as_u16() == 429 {
response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(parse_retry_after)
} else {
None
};
return Err((
SeerError::RdapError(format!("query failed with status {}", status)),
retry_after,
));
}
read_and_parse_rdap_body(response, ¤t, timeout)
.await
.map_err(|e| (e, None))
}
async fn load_bootstrap_data_with_retry(policy: &RetryPolicy) -> Result<BootstrapLoad> {
let executor = RetryExecutor::new(policy.clone());
executor.execute(load_bootstrap_data).await
}
async fn load_bootstrap_data() -> Result<BootstrapLoad> {
debug!("Loading RDAP bootstrap data from IANA");
let http = rdap_http_client()?;
let dns_future = http.get(IANA_BOOTSTRAP_DNS).send();
let ipv4_future = http.get(IANA_BOOTSTRAP_IPV4).send();
let ipv6_future = http.get(IANA_BOOTSTRAP_IPV6).send();
let asn_future = http.get(IANA_BOOTSTRAP_ASN).send();
let (dns_resp, ipv4_resp, ipv6_resp, asn_resp) =
tokio::join!(dns_future, ipv4_future, ipv6_future, asn_future);
async fn read_bootstrap(resp: reqwest::Response) -> Result<BootstrapResponse> {
let body = read_body_capped(resp, MAX_BOOTSTRAP_SIZE, DEFAULT_TIMEOUT, Overflow::Reject)
.await
.map_err(|e| match e {
BodyReadError::Chunk(e) => {
SeerError::RdapBootstrapError(format!("failed to read body: {}", e))
}
BodyReadError::TooLarge => SeerError::RdapBootstrapError(format!(
"bootstrap response too large (exceeds {} bytes)",
MAX_BOOTSTRAP_SIZE
)),
BodyReadError::TimedOut => SeerError::Timeout(format!(
"RDAP bootstrap body read timed out after {:?}",
DEFAULT_TIMEOUT
)),
})?;
serde_json::from_slice(&body).map_err(Into::into)
}
let dns_data = match dns_resp {
Ok(resp) => match read_bootstrap(resp).await {
Ok(data) => Some(data),
Err(e) => {
warn!(error = %e, "Failed to parse DNS bootstrap response");
None
}
},
Err(e) => {
warn!(error = %e, "Failed to fetch DNS bootstrap from IANA");
None
}
};
let ipv4_data = match ipv4_resp {
Ok(resp) => match read_bootstrap(resp).await {
Ok(data) => Some(data),
Err(e) => {
warn!(error = %e, "Failed to parse IPv4 bootstrap response");
None
}
},
Err(e) => {
warn!(error = %e, "Failed to fetch IPv4 bootstrap from IANA");
None
}
};
let ipv6_data = match ipv6_resp {
Ok(resp) => match read_bootstrap(resp).await {
Ok(data) => Some(data),
Err(e) => {
warn!(error = %e, "Failed to parse IPv6 bootstrap response");
None
}
},
Err(e) => {
warn!(error = %e, "Failed to fetch IPv6 bootstrap from IANA");
None
}
};
let asn_data = match asn_resp {
Ok(resp) => match read_bootstrap(resp).await {
Ok(data) => Some(data),
Err(e) => {
warn!(error = %e, "Failed to parse ASN bootstrap response");
None
}
},
Err(e) => {
warn!(error = %e, "Failed to fetch ASN bootstrap from IANA");
None
}
};
fn collect_valid_urls(urls: &[serde_json::Value]) -> Option<Arc<Vec<url::Url>>> {
let mut out = Vec::new();
for u in urls {
if let Some(s) = u.as_str() {
match validate_bootstrap_url(s) {
Ok(parsed) => out.push(parsed),
Err(e) => {
debug!(url = s, error = %e, "Skipping invalid bootstrap URL");
}
}
}
}
if out.is_empty() {
None
} else {
Some(Arc::new(out))
}
}
fn parse_prefix_services(data: BootstrapResponse) -> Vec<(IpRange, Arc<Vec<url::Url>>)> {
let mut out = Vec::new();
for service in data.services {
if service.len() >= 2 {
if let (Some(prefixes), Some(urls)) = (service[0].as_array(), service[1].as_array())
{
if let Some(urls_arc) = collect_valid_urls(urls) {
for prefix in prefixes {
if let Some(prefix_str) = prefix.as_str() {
out.push((
IpRange {
prefix: prefix_str.to_string(),
},
Arc::clone(&urls_arc),
));
}
}
}
}
}
}
out
}
let dns = dns_data
.map(|data| {
let mut dns = HashMap::new();
for service in data.services {
if service.len() >= 2 {
if let (Some(tlds), Some(urls)) = (service[0].as_array(), service[1].as_array())
{
if let Some(urls_arc) = collect_valid_urls(urls) {
for tld in tlds {
if let Some(tld_str) = tld.as_str() {
dns.insert(tld_str.to_lowercase(), Arc::clone(&urls_arc));
}
}
}
}
}
}
dns
})
.filter(|dns| !dns.is_empty());
let ipv4 = ipv4_data
.map(parse_prefix_services)
.filter(|v| !v.is_empty());
let ipv6 = ipv6_data
.map(parse_prefix_services)
.filter(|v| !v.is_empty());
let asn = asn_data
.map(|data| {
let mut asn = Vec::new();
for service in data.services {
if service.len() >= 2 {
if let (Some(ranges), Some(urls)) =
(service[0].as_array(), service[1].as_array())
{
if let Some(urls_arc) = collect_valid_urls(urls) {
for range in ranges {
if let Some(range_str) = range.as_str() {
if let Some((start, end)) = parse_asn_range(range_str) {
asn.push((AsnRange { start, end }, Arc::clone(&urls_arc)));
}
}
}
}
}
}
}
asn
})
.filter(|v| !v.is_empty());
if dns.is_none() && ipv4.is_none() && ipv6.is_none() && asn.is_none() {
return Err(SeerError::RdapBootstrapError(
"all IANA bootstrap registries failed".to_string(),
));
}
info!(
dns_entries = dns.as_ref().map_or(0, HashMap::len),
ipv4_ranges = ipv4.as_ref().map_or(0, Vec::len),
ipv6_ranges = ipv6.as_ref().map_or(0, Vec::len),
asn_ranges = asn.as_ref().map_or(0, Vec::len),
"RDAP bootstrap loaded"
);
Ok(BootstrapLoad {
dns,
ipv4,
ipv6,
asn,
})
}
fn wrap_all_candidates_failed(last_error: Option<SeerError>, candidate_count: usize) -> SeerError {
let last = last_error.unwrap_or_else(|| SeerError::RdapError("no candidates".to_string()));
if candidate_count <= 1 {
return last;
}
match last {
SeerError::Timeout(msg) => SeerError::Timeout(format!(
"all {} RDAP candidate URLs timed out; last error: {}",
candidate_count, msg
)),
other => SeerError::RdapError(format!(
"all {} RDAP candidate URLs failed; last error: {}",
candidate_count, other
)),
}
}
fn build_rdap_urls(bases: &[url::Url], path: &str) -> Vec<url::Url> {
bases
.iter()
.filter_map(|base| {
let base_str = base.as_str();
let normalized = if base_str.ends_with('/') {
base_str.to_string()
} else {
format!("{}/", base_str)
};
url::Url::parse(&normalized).and_then(|u| u.join(path)).ok()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_config_applies_rdap_timeout() {
let mut config = crate::config::SeerConfig::default();
config.timeouts.rdap_secs = 33;
let client = RdapClient::from_config(&config);
assert_eq!(client.timeout, Duration::from_secs(33));
}
#[test]
fn test_default_client_has_retry_policy() {
let client = RdapClient::new();
assert_eq!(client.retry_policy.max_attempts, 3);
}
#[test]
fn parse_retry_after_parses_delta_seconds() {
assert_eq!(parse_retry_after("5"), Some(Duration::from_secs(5)));
assert_eq!(parse_retry_after(" 10 "), Some(Duration::from_secs(10)));
assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
}
#[test]
fn parse_retry_after_rejects_http_date_and_junk() {
assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), None);
assert_eq!(parse_retry_after("soon"), None);
assert_eq!(parse_retry_after(""), None);
}
#[test]
fn effective_retry_delay_prefers_capped_retry_after() {
assert_eq!(
effective_retry_delay(Duration::from_millis(100), Some(Duration::from_secs(5))),
Duration::from_secs(5)
);
assert_eq!(
effective_retry_delay(Duration::from_millis(100), Some(Duration::from_secs(600))),
MAX_RETRY_AFTER
);
}
#[test]
fn effective_retry_delay_falls_back_to_backoff() {
assert_eq!(
effective_retry_delay(Duration::from_millis(250), None),
Duration::from_millis(250)
);
}
#[test]
fn test_client_without_retries() {
let client = RdapClient::new().without_retries();
assert_eq!(client.retry_policy.max_attempts, 1);
}
#[test]
fn test_client_custom_retry_policy() {
let policy = RetryPolicy::new().with_max_attempts(5);
let client = RdapClient::new().with_retry_policy(policy);
assert_eq!(client.retry_policy.max_attempts, 5);
}
#[test]
fn test_cached_bootstrap_expiration() {
let data = BootstrapData {
dns: HashMap::new(),
ipv4: Vec::new(),
ipv6: Vec::new(),
asn: Vec::new(),
};
let cached = CachedBootstrap::new(data);
assert!(!cached.is_expired());
}
#[test]
fn test_rdap_http_client_is_configured() {
let client = rdap_http_client();
assert!(client.is_ok(), "RDAP HTTP client builder must succeed");
}
#[test]
fn test_parse_bootstrap_empty_services() {
let data = BootstrapData {
dns: HashMap::new(),
ipv4: Vec::new(),
ipv6: Vec::new(),
asn: Vec::new(),
};
assert!(RdapClient::get_rdap_urls_for_domain(&data, "example.com").is_none());
assert!(RdapClient::get_rdap_urls_for_asn(&data, 12345).is_none());
}
#[tokio::test]
async fn test_validate_url_not_reserved_rejects_loopback_literal() {
let err = validate_url_not_reserved("https://127.0.0.1/domain/example.com")
.await
.unwrap_err();
assert!(
matches!(err, SeerError::RdapError(ref s) if s.contains("reserved")),
"expected reserved-IP error, got: {:?}",
err
);
}
#[tokio::test]
async fn test_validate_url_not_reserved_rejects_private_ipv4_literal() {
let err = validate_url_not_reserved("https://10.0.0.1/")
.await
.unwrap_err();
assert!(
matches!(err, SeerError::RdapError(ref s) if s.contains("reserved")),
"expected reserved-IP error, got: {:?}",
err
);
}
#[tokio::test]
async fn test_validate_url_not_reserved_rejects_non_https_scheme() {
let err = validate_url_not_reserved("http://93.184.216.34/domain/example.com")
.await
.unwrap_err();
assert!(
matches!(err, SeerError::RdapError(ref s) if s.contains("not https")),
"expected non-https rejection, got: {:?}",
err
);
}
#[tokio::test]
async fn test_validate_url_not_reserved_rejects_ipv6_loopback_literal() {
let err = validate_url_not_reserved("https://[::1]/")
.await
.unwrap_err();
assert!(
matches!(err, SeerError::RdapError(ref s) if s.contains("reserved")),
"expected reserved-IP error, got: {:?}",
err
);
}
#[tokio::test]
async fn test_validate_url_not_reserved_returns_resolved_addrs_for_public_literal() {
let addrs = validate_url_not_reserved("https://8.8.8.8/").await.unwrap();
assert_eq!(addrs.len(), 1);
assert!(addrs[0].ip().is_ipv4());
assert_eq!(addrs[0].port(), 443);
}
#[test]
fn test_build_rdap_urls_preserves_order_and_appends_path() {
let bases = vec![
url::Url::parse("https://rdap.a.example/").unwrap(),
url::Url::parse("https://rdap.b.example").unwrap(), ];
let built = build_rdap_urls(&bases, "domain/example.com");
assert_eq!(built.len(), 2);
assert_eq!(
built[0].as_str(),
"https://rdap.a.example/domain/example.com"
);
assert_eq!(
built[1].as_str(),
"https://rdap.b.example/domain/example.com"
);
}
#[test]
fn test_build_rdap_urls_empty_input_returns_empty() {
let built = build_rdap_urls(&[], "domain/example.com");
assert!(built.is_empty());
}
#[test]
fn test_wrap_all_candidates_failed_preserves_timeout_variant() {
let last = SeerError::Timeout("body read timed out".to_string());
let wrapped = wrap_all_candidates_failed(Some(last), 3);
match wrapped {
SeerError::Timeout(msg) => {
assert!(
msg.contains("all 3 RDAP candidate URLs timed out"),
"expected wrapped timeout message, got: {}",
msg
);
assert!(
msg.contains("body read timed out"),
"expected original message preserved, got: {}",
msg
);
}
other => panic!(
"expected SeerError::Timeout after wrapping a Timeout, got: {:?}",
other
),
}
}
#[test]
fn test_wrap_all_candidates_failed_wraps_non_timeout_as_rdap_error() {
let last = SeerError::RdapError("500 internal error".to_string());
let wrapped = wrap_all_candidates_failed(Some(last), 2);
assert!(
matches!(wrapped, SeerError::RdapError(ref s) if s.contains("all 2 RDAP candidate URLs failed")),
"expected wrapped RdapError, got: {:?}",
wrapped
);
}
#[test]
fn test_wrap_all_candidates_failed_single_candidate_returns_unchanged() {
let last = SeerError::Timeout("single timeout".to_string());
let wrapped = wrap_all_candidates_failed(Some(last), 1);
assert!(
matches!(wrapped, SeerError::Timeout(ref s) if s == "single timeout"),
"expected unchanged Timeout, got: {:?}",
wrapped
);
}
#[test]
fn test_wrap_all_candidates_failed_no_last_error_returns_placeholder() {
let wrapped = wrap_all_candidates_failed(None, 0);
assert!(matches!(wrapped, SeerError::RdapError(_)));
}
static BOOTSTRAP_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[tokio::test]
async fn test_bootstrap_load_notify_wakes_waiter_when_cache_populated() {
let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
{
let mut cache = BOOTSTRAP_CACHE.write().await;
*cache = None;
}
let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
tokio::pin!(notified);
{
let mut cache = BOOTSTRAP_CACHE.write().await;
*cache = Some(CachedBootstrap::new(BootstrapData {
dns: HashMap::new(),
ipv4: Vec::new(),
ipv6: Vec::new(),
asn: Vec::new(),
}));
}
BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
let result = wait_for_in_flight_load(notified).await;
assert!(
result.is_ok(),
"expected waiter to see populated cache, got: {:?}",
result
);
{
let mut cache = BOOTSTRAP_CACHE.write().await;
*cache = None;
}
}
#[tokio::test]
async fn test_bootstrap_load_notify_empty_cache_after_wake_returns_error() {
let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
{
let mut cache = BOOTSTRAP_CACHE.write().await;
*cache = None;
}
let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
tokio::pin!(notified);
BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
let result = wait_for_in_flight_load(notified).await;
assert!(
matches!(
result,
Err(SeerError::RdapBootstrapError(ref s))
if s.contains("throttled and no cache available")
),
"expected throttled error when cache still empty after notify, got: {:?}",
result
);
}
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn mock_rdap_404_is_nonretryable_typed_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let client = RdapClient::new()
.without_retries()
.allowing_reserved_for_tests();
let err = client
.query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
.await
.unwrap_err();
assert!(
matches!(err, SeerError::RdapError(ref m) if m.contains("404")),
"got: {err:?}"
);
}
#[tokio::test]
async fn mock_rdap_429_honors_retry_after_and_succeeds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
r#"{"objectClassName":"domain","handle":"MOCK-1"}"#,
"application/rdap+json",
))
.mount(&server)
.await;
let client = RdapClient::new().allowing_reserved_for_tests();
let resp = client
.query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
.await
.unwrap();
assert_eq!(resp.handle.as_deref(), Some("MOCK-1"));
}
#[tokio::test]
async fn mock_rdap_malformed_body_is_parse_error_not_panic() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_raw("not json", "text/plain"))
.mount(&server)
.await;
let client = RdapClient::new()
.without_retries()
.allowing_reserved_for_tests();
let err = client
.query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
.await
.unwrap_err();
assert!(matches!(err, SeerError::JsonError(_)), "got: {err:?}");
}
#[tokio::test]
async fn mock_rdap_candidate_fallback_uses_second_url() {
let bad = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(500))
.mount(&bad)
.await;
let good = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
r#"{"objectClassName":"domain","handle":"MOCK-2"}"#,
"application/rdap+json",
))
.mount(&good)
.await;
let client = RdapClient::new()
.without_retries()
.allowing_reserved_for_tests();
let urls = vec![
url::Url::parse(&format!("{}/domain/example.com", bad.uri())).unwrap(),
url::Url::parse(&format!("{}/domain/example.com", good.uri())).unwrap(),
];
let resp = client.query_rdap_urls(&urls).await.unwrap();
assert_eq!(resp.handle.as_deref(), Some("MOCK-2"));
}
#[tokio::test]
async fn mock_rdap_404_on_first_candidate_survives_later_non_404_failure() {
let not_found = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(404))
.mount(¬_found)
.await;
let broken = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(500))
.mount(&broken)
.await;
let client = RdapClient::new()
.without_retries()
.allowing_reserved_for_tests();
let urls = vec![
url::Url::parse(&format!("{}/domain/example.com", not_found.uri())).unwrap(),
url::Url::parse(&format!("{}/domain/example.com", broken.uri())).unwrap(),
];
let err = client.query_rdap_urls(&urls).await.unwrap_err();
assert!(
crate::rdap::rdap_error_is_404(&err),
"404 from candidate 1 must survive candidate 2's non-404 failure, got: {err:?}"
);
}
#[tokio::test]
async fn pinned_client_cache_hit_skips_dns_and_revalidation() {
let host = "pinned-cache-hit.seer-test.invalid";
let timeout = Duration::from_secs(2);
let key = (host.to_string(), 443u16, timeout);
let pinned: Vec<SocketAddr> = vec!["127.0.0.1:443".parse().expect("valid addr")];
let client = Client::builder()
.timeout(timeout)
.connect_timeout(timeout)
.resolve_to_addrs(host, &pinned)
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("client builds");
PINNED_CLIENT_CACHE.insert(key.clone(), client);
let err = send_rdap_request(
&format!("https://{}/domain/example.com", host),
timeout,
false,
)
.await
.unwrap_err();
assert!(
matches!(err, SeerError::ReqwestError { .. }),
"cache hit must reach the connect stage (ReqwestError), not fail \
SSRF validation (RdapError) — got: {err:?}"
);
PINNED_CLIENT_CACHE.remove(&key);
}
#[tokio::test]
async fn test_mode_requests_never_populate_pinned_client_cache() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
r#"{"objectClassName":"domain","handle":"MOCK-CACHE"}"#,
"application/rdap+json",
))
.mount(&server)
.await;
let client = RdapClient::new()
.without_retries()
.allowing_reserved_for_tests();
let uri = format!("{}/domain/example.com", server.uri());
let resp = client.query_rdap_with_retry(&uri).await.unwrap();
assert_eq!(resp.handle.as_deref(), Some("MOCK-CACHE"));
let parsed = url::Url::parse(&uri).unwrap();
let host = parsed.host_str().unwrap().to_string();
let port = parsed.port_or_known_default().unwrap();
assert!(
PINNED_CLIENT_CACHE
.get(&(host, port, DEFAULT_TIMEOUT))
.is_none(),
"test-mode request must not populate the shared pinned-client cache"
);
}
use wiremock::matchers::path;
const MOCK_OK_BODY: &str = r#"{"objectClassName":"domain","handle":"MOCK-REDIRECT"}"#;
#[test]
fn is_loopback_url_only_matches_loopback_literals() {
assert!(is_loopback_url("http://127.0.0.1:8080/domain/x"));
assert!(is_loopback_url("http://[::1]:8080/domain/x"));
assert!(!is_loopback_url("https://10.0.0.1/domain/x"));
assert!(!is_loopback_url("https://rdap.example.com/domain/x"));
assert!(!is_loopback_url("not a url"));
}
#[tokio::test]
async fn mock_rdap_303_redirect_is_followed() {
let target = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ip/192.36.148.17"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(MOCK_OK_BODY, "application/rdap+json"),
)
.expect(1)
.mount(&target)
.await;
let origin = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(303)
.insert_header("Location", format!("{}/ip/192.36.148.17", target.uri())),
)
.mount(&origin)
.await;
let client = RdapClient::new()
.without_retries()
.allowing_reserved_for_tests();
let resp = client
.query_rdap_with_retry(&format!("{}/ip/192.36.148.17", origin.uri()))
.await
.unwrap();
assert_eq!(resp.handle.as_deref(), Some("MOCK-REDIRECT"));
}
#[tokio::test]
async fn mock_rdap_relative_redirect_is_resolved_against_current_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/old/domain/example.com"))
.respond_with(
ResponseTemplate::new(301).insert_header("Location", "/new/domain/example.com"),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/new/domain/example.com"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(MOCK_OK_BODY, "application/rdap+json"),
)
.mount(&server)
.await;
let client = RdapClient::new()
.without_retries()
.allowing_reserved_for_tests();
let resp = client
.query_rdap_with_retry(&format!("{}/old/domain/example.com", server.uri()))
.await
.unwrap();
assert_eq!(resp.handle.as_deref(), Some("MOCK-REDIRECT"));
}
async fn redirect_error(location: &str) -> SeerError {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(302).insert_header("Location", location))
.mount(&server)
.await;
let client = RdapClient::new()
.without_retries()
.allowing_reserved_for_tests();
client
.query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
.await
.unwrap_err()
}
#[tokio::test]
async fn mock_rdap_redirect_to_plain_http_is_refused() {
let err = redirect_error("http://93.184.216.34/domain/example.com").await;
assert!(
matches!(err, SeerError::RdapError(ref m) if m.contains("not https")),
"got: {err:?}"
);
}
#[tokio::test]
async fn mock_rdap_redirect_to_reserved_address_is_refused() {
for location in [
"https://10.0.0.1/domain/example.com",
"https://169.254.169.254/latest/meta-data/",
] {
let err = redirect_error(location).await;
assert!(
matches!(err, SeerError::RdapError(ref m) if m.contains("reserved")),
"{location}: got {err:?}"
);
}
}
#[tokio::test]
async fn mock_rdap_redirect_loop_is_detected() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/a"))
.respond_with(ResponseTemplate::new(307).insert_header("Location", "/b"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/b"))
.respond_with(ResponseTemplate::new(307).insert_header("Location", "/a"))
.mount(&server)
.await;
let client = RdapClient::new()
.without_retries()
.allowing_reserved_for_tests();
let err = client
.query_rdap_with_retry(&format!("{}/a", server.uri()))
.await
.unwrap_err();
assert!(
matches!(err, SeerError::RdapError(ref m) if m.contains("loop")),
"got: {err:?}"
);
}
#[tokio::test]
async fn mock_rdap_redirect_chain_is_capped() {
let server = MockServer::start().await;
for hop in 0..=MAX_RDAP_REDIRECTS {
Mock::given(method("GET"))
.and(path(format!("/hop{hop}")))
.respond_with(
ResponseTemplate::new(308)
.insert_header("Location", format!("/hop{}", hop + 1)),
)
.mount(&server)
.await;
}
let client = RdapClient::new()
.without_retries()
.allowing_reserved_for_tests();
let err = client
.query_rdap_with_retry(&format!("{}/hop0", server.uri()))
.await
.unwrap_err();
assert!(
matches!(err, SeerError::RdapError(ref m) if m.contains("exceeded")),
"got: {err:?}"
);
}
#[tokio::test]
async fn mock_rdap_sends_accept_header_with_json_fallback() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(|req: &wiremock::Request| {
req.headers.get("accept").and_then(|v| v.to_str().ok()) == Some(RDAP_ACCEPT)
})
.respond_with(ResponseTemplate::new(200).set_body_raw(MOCK_OK_BODY, "application/json"))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(406))
.mount(&server)
.await;
let client = RdapClient::new()
.without_retries()
.allowing_reserved_for_tests();
let resp = client
.query_rdap_with_retry(&format!("{}/domain/example.sn", server.uri()))
.await
.unwrap();
assert_eq!(resp.handle.as_deref(), Some("MOCK-REDIRECT"));
assert!(RDAP_ACCEPT.contains("application/json"));
}
fn url_arc(u: &str) -> Arc<Vec<url::Url>> {
Arc::new(vec![url::Url::parse(u).expect("valid url")])
}
fn full_load() -> BootstrapLoad {
let mut dns = HashMap::new();
dns.insert("com".to_string(), url_arc("https://rdap.verisign.example/"));
BootstrapLoad {
dns: Some(dns),
ipv4: Some(vec![(
IpRange {
prefix: "8.0.0.0/8".to_string(),
},
url_arc("https://rdap.arin.example/"),
)]),
ipv6: Some(Vec::new()),
asn: Some(vec![(
AsnRange { start: 1, end: 10 },
url_arc("https://rdap.arin.example/"),
)]),
}
}
#[test]
fn complete_bootstrap_load_gets_full_ttl() {
let load = full_load();
assert!(load.is_complete());
assert_eq!(load.ttl(), BOOTSTRAP_TTL);
}
#[test]
fn partial_bootstrap_load_keeps_previous_section_and_short_ttl() {
let mut previous = Some(CachedBootstrap::with_ttl(
full_load().merge_over(None),
Duration::ZERO,
));
std::thread::sleep(Duration::from_millis(2));
assert!(previous.as_ref().is_some_and(CachedBootstrap::is_expired));
let mut partial = full_load();
partial.dns = None;
assert!(!partial.is_complete());
store_bootstrap_load(&mut previous, partial);
let cached = previous.expect("cache populated");
assert_eq!(cached.ttl, BOOTSTRAP_PARTIAL_TTL);
assert!(
RdapClient::get_rdap_urls_for_domain(&cached.data, "example.com").is_some(),
"the failed dns section must be carried over, not wiped"
);
assert!(RdapClient::get_rdap_urls_for_asn(&cached.data, 5).is_some());
}
#[test]
fn partial_cold_bootstrap_load_leaves_failed_section_empty() {
let mut cache = None;
let mut partial = full_load();
partial.dns = None;
store_bootstrap_load(&mut cache, partial);
let cached = cache.expect("cache populated");
assert_eq!(cached.ttl, BOOTSTRAP_PARTIAL_TTL);
assert!(RdapClient::get_rdap_urls_for_domain(&cached.data, "example.com").is_none());
}
#[test]
fn bootstrap_store_does_not_overwrite_fresh_data() {
let mut fresh = full_load();
fresh.dns = Some(HashMap::from([(
"org".to_string(),
url_arc("https://rdap.pir.example/"),
)]));
let mut cache = Some(CachedBootstrap::new(fresh.merge_over(None)));
store_bootstrap_load(&mut cache, full_load());
let cached = cache.expect("cache populated");
assert!(RdapClient::get_rdap_urls_for_domain(&cached.data, "example.org").is_some());
assert!(RdapClient::get_rdap_urls_for_domain(&cached.data, "example.com").is_none());
}
#[tokio::test]
async fn wait_returns_immediately_when_no_load_is_in_flight() {
let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
{
let mut cache = BOOTSTRAP_CACHE.write().await;
*cache = None;
}
assert!(!BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst));
let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
tokio::pin!(notified);
let result =
tokio::time::timeout(Duration::from_secs(2), wait_for_in_flight_load(notified))
.await
.expect("must not block when no load is in flight");
assert!(
matches!(result, Err(SeerError::RdapBootstrapError(ref s)) if s.contains("throttled")),
"got: {result:?}"
);
}
#[tokio::test]
async fn wait_blocks_until_in_flight_load_finishes() {
let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
{
let mut cache = BOOTSTRAP_CACHE.write().await;
*cache = None;
}
let load = BootstrapLoadGuard::start();
let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
tokio::pin!(notified);
let loader = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
{
let mut cache = BOOTSTRAP_CACHE.write().await;
*cache = Some(CachedBootstrap::new(full_load().merge_over(None)));
}
drop(load);
});
let result =
tokio::time::timeout(Duration::from_secs(5), wait_for_in_flight_load(notified))
.await
.expect("waiter must wake via the loader's notify");
assert!(result.is_ok(), "got: {result:?}");
loader.await.expect("loader joined");
assert!(!BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst));
let mut cache = BOOTSTRAP_CACHE.write().await;
*cache = None;
}
}