use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use serde::Deserialize;
use crate::domain::Tld;
use crate::error::{Error, Result};
use crate::registry::{Endpoint, Registry, RegistryProvider};
const BUNDLED_JSON: &str = include_str!("../../data/rdap-bootstrap.json");
pub const IANA_BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/dns.json";
#[derive(Debug, Clone)]
pub struct BootstrapRegistry {
by_tld: HashMap<Tld, Arc<Registry>>,
publication: Option<String>,
origin: String,
}
impl BootstrapRegistry {
pub fn from_json(json: &str, origin: impl Into<String>) -> Result<Self> {
let origin = origin.into();
let file: BootstrapFile = serde_json::from_str(json)
.map_err(|error| Error::Definitions(format!("{origin}: {error}")))?;
let mut by_tld: HashMap<Tld, Arc<Registry>> = HashMap::new();
for (index, service) in file.services.iter().enumerate() {
let (raw_tlds, urls) = match service.as_slice() {
[tlds, urls] => (tlds, urls),
_ => {
return Err(Error::Definitions(format!(
"{origin}: services[{index}] is not a [tlds, urls] pair"
)))
}
};
let mut tlds = Vec::with_capacity(raw_tlds.len());
for raw in raw_tlds {
if let Ok(tld) = Tld::parse(raw) {
tlds.push(tld);
}
}
if tlds.is_empty() {
continue;
}
let endpoints: Vec<Endpoint> = urls
.iter()
.filter(|url| !url.trim().is_empty())
.map(|url| Endpoint::rdap(url.trim()))
.collect();
if endpoints.is_empty() {
continue;
}
let registry = Registry::builder(tlds.clone())
.endpoints(endpoints)
.note(format!("RDAP endpoint from {origin}"))
.build_shared();
for tld in tlds {
by_tld.insert(tld, Arc::clone(®istry));
}
}
if by_tld.is_empty() {
return Err(Error::Definitions(format!(
"{origin}: no usable RDAP services found"
)));
}
Ok(BootstrapRegistry {
by_tld,
publication: file.publication,
origin,
})
}
pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let json = std::fs::read_to_string(path)
.map_err(|error| Error::Definitions(format!("{}: {error}", path.display())))?;
BootstrapRegistry::from_json(&json, path.display().to_string())
}
pub fn bundled() -> Arc<BootstrapRegistry> {
static BUNDLED: OnceLock<Arc<BootstrapRegistry>> = OnceLock::new();
Arc::clone(BUNDLED.get_or_init(|| {
Arc::new(
BootstrapRegistry::try_bundled()
.expect("bundled data/rdap-bootstrap.json is not a valid RFC 9224 file"),
)
}))
}
pub fn try_bundled() -> Result<Self> {
BootstrapRegistry::from_json(BUNDLED_JSON, "bundled IANA RDAP bootstrap")
}
#[cfg(feature = "iana-bootstrap")]
pub fn fetch() -> Result<Self> {
BootstrapRegistry::fetch_from(IANA_BOOTSTRAP_URL)
}
#[cfg(feature = "iana-bootstrap")]
pub fn fetch_from(url: &str) -> Result<Self> {
let response = reqwest::blocking::get(url).map_err(|error| Error::Connect {
server: url.to_string(),
source: std::io::Error::other(error),
})?;
let status = response.status();
if !status.is_success() {
return Err(Error::Http {
url: url.to_string(),
status: status.as_u16(),
});
}
let body = response.text().map_err(|error| Error::Io {
server: url.to_string(),
source: std::io::Error::other(error),
})?;
BootstrapRegistry::from_json(&body, url)
}
#[cfg(all(feature = "iana-bootstrap", feature = "async"))]
pub async fn fetch_async() -> Result<Self> {
BootstrapRegistry::fetch_from_async(IANA_BOOTSTRAP_URL).await
}
#[cfg(all(feature = "iana-bootstrap", feature = "async"))]
pub async fn fetch_from_async(url: &str) -> Result<Self> {
let response = reqwest::get(url).await.map_err(|error| Error::Connect {
server: url.to_string(),
source: std::io::Error::other(error),
})?;
let status = response.status();
if !status.is_success() {
return Err(Error::Http {
url: url.to_string(),
status: status.as_u16(),
});
}
let body = response.text().await.map_err(|error| Error::Io {
server: url.to_string(),
source: std::io::Error::other(error),
})?;
BootstrapRegistry::from_json(&body, url)
}
pub fn publication(&self) -> Option<&str> {
self.publication.as_deref()
}
pub fn origin(&self) -> &str {
&self.origin
}
pub fn len(&self) -> usize {
self.by_tld.len()
}
pub fn is_empty(&self) -> bool {
self.by_tld.is_empty()
}
}
impl RegistryProvider for BootstrapRegistry {
fn get(&self, tld: &Tld) -> Option<Arc<Registry>> {
self.by_tld.get(tld).map(Arc::clone)
}
fn tlds(&self) -> Vec<Tld> {
let mut tlds: Vec<Tld> = self.by_tld.keys().cloned().collect();
tlds.sort();
tlds
}
fn describe(&self) -> String {
match &self.publication {
Some(published) => format!(
"{} ({} tlds, published {published})",
self.origin,
self.by_tld.len()
),
None => format!("{} ({} tlds)", self.origin, self.by_tld.len()),
}
}
}
#[derive(Debug, Deserialize)]
struct BootstrapFile {
#[serde(default)]
publication: Option<String>,
#[serde(default)]
services: Vec<Vec<Vec<String>>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_snapshot_covers_far_more_than_the_curated_list() {
let registry = BootstrapRegistry::bundled();
assert!(
registry.len() > 1000,
"expected the full IANA list, got {}",
registry.len()
);
assert!(registry.publication().is_some());
}
#[test]
fn bundled_snapshot_yields_rdap_only() {
let registry = BootstrapRegistry::bundled();
let com = registry.get(&Tld::parse("com").unwrap()).unwrap();
assert!(com.endpoints().iter().all(Endpoint::is_rdap));
assert!(!com.endpoints().is_empty());
}
#[test]
fn bundled_snapshot_query_urls_are_well_formed() {
let registry = BootstrapRegistry::bundled();
let com = registry.get(&Tld::parse("com").unwrap()).unwrap();
let Endpoint::Rdap(endpoint) = &com.endpoints()[0] else {
panic!("expected an RDAP endpoint");
};
let url = endpoint.query_url("example.com");
assert!(url.contains("/domain/example.com"), "{url}");
assert!(url.starts_with("http"), "{url}");
}
#[test]
fn covers_internationalised_suffixes() {
let registry = BootstrapRegistry::bundled();
assert!(
registry.get(&Tld::parse("xn--80asehdb").unwrap()).is_some(),
"the bundled snapshot lists no .онлайн"
);
assert!(registry.get(&Tld::parse("онлайн").unwrap()).is_some());
let idn_count = registry.tlds().iter().filter(|tld| tld.is_idn()).count();
assert!(
idn_count > 50,
"expected the internationalised suffixes, got {idn_count}"
);
}
#[test]
fn parses_a_minimal_document() {
let json = r#"{
"publication": "2026-01-01T00:00:00Z",
"services": [[["example", "test"], ["https://rdap.example/"]]]
}"#;
let registry = BootstrapRegistry::from_json(json, "test").unwrap();
assert_eq!(registry.len(), 2);
assert_eq!(registry.publication(), Some("2026-01-01T00:00:00Z"));
let entry = registry.get(&Tld::parse("test").unwrap()).unwrap();
assert_eq!(entry.endpoints(), [Endpoint::rdap("https://rdap.example/")]);
}
#[test]
fn skips_unusable_services_but_keeps_the_rest() {
let json = r#"{"services":[
[["-bad"], ["https://rdap.example/"]],
[["nourls"], []],
[["good"], ["https://rdap.example/"]]
]}"#;
let registry = BootstrapRegistry::from_json(json, "test").unwrap();
assert_eq!(registry.len(), 1);
assert!(registry.get(&Tld::parse("good").unwrap()).is_some());
}
#[test]
fn rejects_documents_with_nothing_usable() {
assert!(BootstrapRegistry::from_json(r#"{"services":[]}"#, "test").is_err());
assert!(BootstrapRegistry::from_json("{", "test").is_err());
assert!(
BootstrapRegistry::from_json(r#"{"services":[[["a"],["u"],["extra"]]]}"#, "test")
.is_err()
);
}
}