use std::time::Duration;
use serde_json::Value;
use crate::limit::{Pacer, Refusal};
use crate::lookup::outcome::{Reason, scrub};
const MAX_BODY_BYTES: usize = 256 * 1024;
use crate::lookup::registry::host_of;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Verdict {
Available(Option<Box<Value>>),
Taken(Box<Value>),
Unknown(Reason),
}
pub(crate) async fn query(
client: &reqwest::Client,
pacer: &Pacer,
services: &[String],
domain: &str,
timeout: Duration,
) -> (Verdict, Option<String>) {
let mut last_reason = Reason::NoService;
for base_url in services {
let host = host_of(base_url);
let Ok(permit) = pacer.acquire_patiently(host, timeout).await else {
last_reason = Reason::RateLimited;
continue;
};
let url = format!("{base_url}domain/{domain}");
let attempt = client
.get(&url)
.header("Accept", "application/rdap+json, application/json")
.timeout(timeout)
.send()
.await;
drop(permit);
match attempt {
Err(error) => {
pacer.record_refusal(host, &Refusal::Dropped).await;
last_reason = if error.is_timeout() {
Reason::TimedOut
} else {
Reason::Unreachable
};
}
Ok(response) => {
let status = response.status().as_u16();
if status == 429 || status == 403 || (500..600).contains(&status) {
let retry_after = retry_after_of(&response);
let refusal = if status == 403 {
Refusal::Blocked
} else {
Refusal::Throttled { retry_after }
};
pacer.record_refusal(host, &refusal).await;
last_reason = match status {
403 => Reason::Blocked,
500..600 => Reason::ServerError { status },
_ => Reason::RateLimited,
};
continue;
}
if status != 404 && !(200..300).contains(&status) {
last_reason = Reason::Malformed {
detail: format!("http {status}"),
};
continue;
}
if status == 404 {
if !speaks_rdap(&response) {
last_reason = Reason::Malformed {
detail: "a 404 that was not an answer from a registry".to_owned(),
};
continue;
}
pacer.record_success(host).await;
return (Verdict::Available(None), Some(host.to_owned()));
}
pacer.record_success(host).await;
let raw = match read_capped_body(response).await {
Ok(raw) => raw,
Err(reason) => {
last_reason = reason;
continue;
}
};
let Ok(body) = serde_json::from_slice::<Value>(&raw) else {
last_reason = Reason::Malformed {
detail: "the answer was not readable".to_owned(),
};
continue;
};
match classify(&body, domain) {
Ok(answer) => return (answer, Some(host.to_owned())),
Err(reason) => last_reason = reason,
}
}
}
}
(Verdict::Unknown(last_reason), None)
}
fn speaks_rdap(response: &reqwest::Response) -> bool {
let Some(kind) = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
else {
return response.content_length() == Some(0);
};
let kind = kind.to_ascii_lowercase();
kind.contains("rdap") || kind.contains("json")
}
async fn read_capped_body(response: reqwest::Response) -> Result<Vec<u8>, Reason> {
let mut response = response;
let mut body = Vec::new();
loop {
match response.chunk().await {
Ok(Some(chunk)) => {
if body.len().saturating_add(chunk.len()) > MAX_BODY_BYTES {
return Err(Reason::Malformed {
detail: "the answer was longer than this tool will read".to_owned(),
});
}
body.extend_from_slice(&chunk);
}
Ok(None) => return Ok(body),
Err(error) if error.is_timeout() => return Err(Reason::TimedOut),
Err(_) => {
return Err(Reason::Malformed {
detail: "the answer stopped part-way".to_owned(),
});
}
}
}
}
fn classify(body: &Value, domain: &str) -> Result<Verdict, Reason> {
if let Some(raw) = body.get("errorCode") {
let code = raw
.as_u64()
.or_else(|| raw.as_str().and_then(|text| text.trim().parse().ok()));
let Some(code) = code else {
return Err(Reason::Malformed {
detail: "the registry reported an error we could not read".to_owned(),
});
};
if code == 404 {
return Ok(Verdict::Available(Some(Box::new(body.clone()))));
}
if code == 429 {
return Err(Reason::RateLimited);
}
return Err(Reason::Malformed {
detail: format!("registry error {code}"),
});
}
if !is_domain_record(body) {
if says_not_found(body, domain) {
return Ok(Verdict::Available(Some(Box::new(body.clone()))));
}
return Err(Reason::Malformed {
detail: "the registry answered in a shape we do not recognise".to_owned(),
});
}
if let Some(answered) = subject_of(body)
&& answered != domain.trim_end_matches('.').to_lowercase()
{
return Err(Reason::WrongSubject {
answered_about: answered,
});
}
Ok(Verdict::Taken(Box::new(body.clone())))
}
fn is_domain_record(body: &Value) -> bool {
body.get("objectClassName").and_then(Value::as_str) == Some("domain")
|| body.get("ldhName").and_then(Value::as_str).is_some()
}
fn subject_of(body: &Value) -> Option<String> {
body.get("ldhName")
.and_then(Value::as_str)
.map(|name| scrub(&name.trim_end_matches('.').to_lowercase()))
}
fn stated_text(body: &Value) -> String {
const SAYS_STATUS: &[&str] = &[
"title",
"description",
"errortitle",
"status",
"message",
"errorcode",
];
fn gather(body: &Value, said: &mut String) {
let Some(fields) = body.as_object() else {
return;
};
for (key, value) in fields {
let key = key.to_ascii_lowercase();
if key == "errors" || key == "error" {
match value {
Value::Array(items) => items.iter().for_each(|item| gather(item, said)),
other => gather(other, said),
}
continue;
}
if !SAYS_STATUS.contains(&key.as_str()) {
continue;
}
match value {
Value::String(text) => said.push_str(text),
Value::Array(items) => {
for item in items.iter().filter_map(Value::as_str) {
said.push_str(item);
said.push(' ');
}
}
_ => {}
}
said.push(' ');
}
}
if let Some(text) = body.as_str() {
return text.to_lowercase();
}
let mut said = String::new();
gather(body, &mut said);
said.to_lowercase()
}
fn says_not_found(body: &Value, domain: &str) -> bool {
let stated = stated_text(body);
let absent = stated.contains("not_found")
|| stated.contains("not found")
|| stated.contains("does not exist")
|| stated.contains("no object found");
let whole = body.to_string().to_lowercase();
absent && !crate::lookup::verdict::refused(&whole, domain)
}
pub(crate) fn raw_text(body: &Value) -> String {
let text = serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string());
crate::lookup::outcome::scrub_unbounded(&text)
}
fn retry_after_of(response: &reqwest::Response) -> Option<Duration> {
let raw = response.headers().get("retry-after")?.to_str().ok()?;
if let Ok(seconds) = raw.trim().parse::<u64>() {
return Some(Duration::from_secs(seconds));
}
None
}
#[cfg(test)]
mod tests {
fn answered(kind: Option<&str>, length: Option<u64>) -> bool {
let mut built = ::http::Response::builder().status(404);
if let Some(kind) = kind {
built = built.header(::http::header::CONTENT_TYPE, kind);
}
if let Some(length) = length {
built = built.header(::http::header::CONTENT_LENGTH, length.to_string());
}
let raw = built.body(Vec::new()).expect("a response builds");
speaks_rdap(&reqwest::Response::from(raw))
}
#[test]
fn a_404_counts_as_an_answer_only_when_a_registry_sent_it() {
assert!(answered(Some("application/rdap+json"), None));
assert!(answered(Some("application/json; charset=utf-8"), Some(0)));
assert!(answered(Some("application/rdap+JSON"), Some(120)));
assert!(answered(None, Some(0)));
assert!(answered(None, None));
assert!(!answered(Some("text/html"), Some(146)));
assert!(!answered(Some("text/html; charset=iso-8859-1"), None));
assert!(!answered(Some("text/plain"), Some(9)));
assert!(!answered(Some("application/xml"), Some(64)));
}
use super::*;
use serde_json::json;
#[test]
fn a_domain_record_reads_as_held() {
let body = json!({"objectClassName": "domain", "ldhName": "apple.com"});
assert!(matches!(
classify(&body, "apple.com"),
Ok(Verdict::Taken(_))
));
}
#[test]
fn an_error_code_of_404_reads_as_free() {
let body = json!({"errorCode": 404, "title": "Not Found"});
assert_eq!(
classify(&body, "nothing.com"),
Ok(Verdict::Available(Some(Box::new(body.clone()))))
);
}
#[test]
fn a_private_not_found_shape_reads_as_free() {
let body = json!({
"errors": [{"errorCode": "NOT_FOUND_DOMAIN_NAME_WITH_NAME",
"message": "No domain corresponding to example.test has been found"}]
});
assert_eq!(
classify(&body, "example.test"),
Ok(Verdict::Available(Some(Box::new(body.clone()))))
);
}
#[test]
fn a_throttle_message_is_never_read_as_free() {
let body = json!({
"title": "Rate limit exceeded",
"description": ["endpoint not found or too many requests"]
});
assert!(!says_not_found(&body, "example.test"));
assert!(matches!(
classify(&body, "x.com"),
Err(Reason::Malformed { .. })
));
}
#[test]
fn an_error_code_of_429_reads_as_rate_limited() {
let body = json!({"errorCode": 429});
assert_eq!(classify(&body, "x.com"), Err(Reason::RateLimited));
}
#[test]
fn a_record_about_another_name_is_refused() {
let body = json!({"objectClassName": "domain", "ldhName": "ac.bd"});
match classify(&body, "example.ac.bd") {
Err(Reason::WrongSubject { answered_about }) => {
assert_eq!(answered_about, "ac.bd");
}
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn a_trailing_dot_does_not_look_like_another_name() {
let body = json!({"objectClassName": "domain", "ldhName": "apple.com."});
assert!(matches!(
classify(&body, "apple.com"),
Ok(Verdict::Taken(_))
));
}
#[test]
fn case_does_not_look_like_another_name() {
let body = json!({"objectClassName": "domain", "ldhName": "APPLE.COM"});
assert!(matches!(
classify(&body, "apple.com"),
Ok(Verdict::Taken(_))
));
}
#[test]
fn an_unrecognised_shape_is_unknown_not_free() {
let body = json!({"something": "else"});
assert!(matches!(
classify(&body, "x.com"),
Err(Reason::Malformed { .. })
));
}
#[test]
fn a_registry_error_that_is_neither_missing_nor_throttled_is_unknown() {
let body = json!({"errorCode": 500, "title": "Internal Server Error"});
match classify(&body, "example.bd") {
Err(Reason::Malformed { detail }) => assert!(detail.contains("500"), "{detail}"),
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn an_error_code_is_read_before_the_record_shape() {
let body = json!({"errorCode": 404, "objectClassName": "domain", "ldhName": "example.bd"});
assert_eq!(
classify(&body, "example.bd"),
Ok(Verdict::Available(Some(Box::new(body.clone()))))
);
}
#[test]
fn every_private_way_of_saying_nothing_is_here_reads_as_free() {
for phrase in [
"not_found",
"not found",
"does not exist",
"no object found",
] {
let body = json!({"message": format!("the name {phrase} in this zone")});
assert_eq!(
classify(&body, "example.bd"),
Ok(Verdict::Available(Some(Box::new(body.clone())))),
"{phrase}"
);
}
}
#[test]
fn a_quota_or_too_many_message_is_never_read_as_free() {
for body in [
json!({"title": "Quota exceeded", "description": "domain not found"}),
json!({"title": "Too many requests", "description": "no object found"}),
] {
assert!(!says_not_found(&body, "example.test"), "{body}");
assert!(matches!(
classify(&body, "example.bd"),
Err(Reason::Malformed { .. })
));
}
}
#[test]
fn a_control_byte_in_the_answered_name_never_reaches_the_report() {
let body = json!({"objectClassName": "domain", "ldhName": "other\u{1b}[2K.bd"});
match classify(&body, "asked.bd") {
Err(Reason::WrongSubject { answered_about }) => {
assert!(
!answered_about.chars().any(char::is_control),
"{answered_about:?} would rewrite the screen"
);
}
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn a_body_with_nothing_in_it_is_unknown_not_free() {
assert!(matches!(
classify(&json!({}), "example.bd"),
Err(Reason::Malformed { .. })
));
assert!(matches!(
classify(&json!([]), "example.bd"),
Err(Reason::Malformed { .. })
));
}
#[test]
fn a_domain_record_is_recognised_by_either_marker() {
assert!(is_domain_record(&json!({"objectClassName": "domain"})));
assert!(is_domain_record(&json!({"ldhName": "x.com"})));
assert!(!is_domain_record(&json!({"handle": "abc"})));
}
#[test]
fn a_free_name_whose_letters_spell_a_refusal_word_is_still_free() {
for (domain, message) in [
(
"corporate.test",
"No domain corresponding to corporate.test has been found",
),
("aggregate.test", "aggregate.test not found"),
("abuse-report.test", "abuse-report.test not found"),
("throttle.test", "throttle.test not found"),
("forbidden-fruit.test", "forbidden-fruit.test not found"),
] {
let body: Value = serde_json::json!({
"errorCode": 404_i32,
"title": "Not Found",
"description": [message],
});
assert!(
says_not_found(&body, domain),
"{domain} is free, and the letters in its own name must not read as a refusal"
);
}
}
#[test]
fn a_service_talking_about_itself_never_reports_a_name_free() {
let notice_only: Value = serde_json::json!({
"notices": [{
"title": "Terms of Use",
"description": [
"If a record is not found, that does not mean the name is available."
]
}]
});
assert!(
!says_not_found(¬ice_only, "sazzad.test"),
"a terms block is not an answer about the name"
);
let remarks_only: Value = serde_json::json!({
"remarks": [{"description": ["object not found in this mirror"]}]
});
assert!(!says_not_found(&remarks_only, "sazzad.test"));
let stated: Value = serde_json::json!({"title": "Domain not found"});
assert!(says_not_found(&stated, "sazzad.test"));
let private: Value = serde_json::json!({
"errors": [{
"errorCode": "NOT_FOUND_DOMAIN_NAME_WITH_NAME",
"message": "No domain corresponding to sazzad.test has been found"
}]
});
assert!(says_not_found(&private, "sazzad.test"));
}
#[test]
fn a_real_refusal_is_still_refused_even_next_to_an_innocent_name() {
let body: Value = serde_json::json!({
"title": "not found",
"notices": ["Your quota exceeded the published allowance"],
});
assert!(
!says_not_found(&body, "corporate.test"),
"a refusal standing as its own words still vetoes a free reading"
);
}
}