reserve-core 0.1.0

Core lookup, catalog, and rate-limiting engine behind the reserve domain finder
Documentation
//! Asking a registry directly whether a name is registered; only this protocol settles the question.

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,
    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(lease) = 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(lease);

        match attempt {
            Err(error) => {
                // @docgen A timeout and a dropped connection are the same signal: the server chose not to answer, so both count as backpressure.
                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();

                // @docgen Pushback is never an answer about the name; reading a throttle as "no such domain" would report a whole zone free.
                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 = if status == 403 {
                        Reason::Blocked
                    } else {
                        Reason::RateLimited
                    };
                    continue;
                }

                // @docgen A 400 or 451 is a refusal about the request, so counting it as success would raise concurrency against a host rejecting everything.
                if status != 404 && !(200..300).contains(&status) {
                    last_reason = Reason::Malformed {
                        detail: format!("http {status}"),
                    };
                    continue;
                }

                pacer.record_success(host).await;

                if status == 404 {
                    return (Verdict::Available, Some(host.to_owned()));
                }

                let Ok(raw) = read_capped(response).await else {
                    last_reason = Reason::Malformed {
                        detail: "the answer was not readable".to_owned(),
                    };
                    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)
}

/// @docgen Both text paths cap their reads; without the same cap here a compressed body could exhaust memory.
async fn read_capped(response: reqwest::Response) -> Result<Vec<u8>, ()> {
    let mut response = response;
    let mut body = Vec::new();
    while let Some(chunk) = response.chunk().await.map_err(|_| ())? {
        if body.len().saturating_add(chunk.len()) > MAX_BODY_BYTES {
            return Err(());
        }
        body.extend_from_slice(&chunk);
    }
    Ok(body)
}

fn classify(body: &Value, domain: &str) -> Result<Verdict, Reason> {
    // @docgen Some services report the error in the body rather than the HTTP status.
    if let Some(code) = body.get("errorCode").and_then(Value::as_u64) {
        if code == 404 {
            return Ok(Verdict::Available);
        }
        if code == 429 {
            return Err(Reason::RateLimited);
        }
        return Err(Reason::Malformed {
            detail: format!("registry error {code}"),
        });
    }

    if !is_domain_record(body) {
        // @docgen A non-record body is either a private "no such name" shape or something that must never be read as one.
        if says_not_found(body) {
            return Ok(Verdict::Available);
        }
        return Err(Reason::Malformed {
            detail: "the registry answered in a shape we do not recognise".to_owned(),
        });
    }

    // @docgen Some registries answer an undelegated name with its parent zone's record, which says nothing about the name asked.
    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()))
}

/// @docgen A throttle message can contain "not found", so a rate-limit phrase must veto reading the body as free.
fn says_not_found(body: &Value) -> bool {
    let text = body.to_string().to_lowercase();
    let absent = text.contains("not_found")
        || text.contains("not found")
        || text.contains("does not exist")
        || text.contains("no object found");
    let throttled = text.contains("rate") || text.contains("too many") || text.contains("quota");
    absent && !throttled
}

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));
    }
    // @docgen The HTTP-date form is not computed against the clock here; the pacer's own default wait covers it.
    None
}

#[cfg(test)]
mod tests {
    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));
    }

    #[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));
    }

    #[test]
    fn a_throttle_message_is_never_read_as_free() {
        // @docgen The words "not found" appear beside the rate limit, and reading that as free would report a whole zone available.
        let body = json!({
            "title": "Rate limit exceeded",
            "description": ["endpoint not found or too many requests"]
        });
        assert!(!says_not_found(&body));
        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() {
        // @docgen Some registries answer an undelegated third-level name with its parent zone's record, which says nothing about the name asked.
        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));
    }

    #[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),
                "{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), "{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"})));
    }
}