Skip to main content

seer_core/rdap/
mod.rs

1mod bootstrap;
2mod client;
3mod types;
4
5pub use client::RdapClient;
6pub use types::{ContactInfo, RdapResponse};
7
8use std::net::IpAddr;
9
10use crate::error::{Result, SeerError};
11
12/// Classified RDAP routing decision for a free-form query string.
13///
14/// Separated from [`auto_lookup`] so the routing rules can be covered by
15/// pure unit tests that do not require network or an [`RdapClient`].
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum RdapRoute {
18    /// Query was an IP literal (v4 or v6).
19    Ip(IpAddr),
20    /// Query matched the `AS<digits>` form with no embedded dots.
21    Asn(u32),
22    /// Anything else — dispatched to domain lookup.
23    Domain(String),
24}
25
26/// Classifies a free-form RDAP query into an [`RdapRoute`].
27///
28/// Rules (applied in order):
29///
30/// 1. If `query` parses as an [`IpAddr`], route to [`RdapRoute::Ip`].
31/// 2. If `query` (case-insensitive) starts with `AS`, the remainder is all
32///    ASCII digits, and the trimmed query contains no `.`, route to
33///    [`RdapRoute::Asn`].
34/// 3. Otherwise, route to [`RdapRoute::Domain`].
35///
36/// The `contains('.')` guard is the load-bearing check that prevents real
37/// domains like `as1234.io` from being misclassified as an ASN query — a
38/// regression that existed while RDAP auto-routing lived in the Python
39/// `rdap()` wrapper.
40pub fn classify(query: &str) -> Result<RdapRoute> {
41    let trimmed = query.trim();
42    if trimmed.is_empty() {
43        return Err(SeerError::InvalidInput("empty RDAP query".to_string()));
44    }
45
46    // 1) IP literal (v4 or v6).
47    if let Ok(ip) = trimmed.parse::<IpAddr>() {
48        return Ok(RdapRoute::Ip(ip));
49    }
50
51    // 2) ASN: "AS<digits>" with no '.' anywhere in the trimmed query.
52    //    The no-dot check prevents misrouting real domains that start with
53    //    "AS" (e.g. as1234.io).
54    let upper = trimmed.to_uppercase();
55    if let Some(rest) = upper.strip_prefix("AS") {
56        if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) && !trimmed.contains('.') {
57            let asn: u32 = rest
58                .parse()
59                .map_err(|_| SeerError::InvalidInput(format!("invalid ASN: {query}")))?;
60            return Ok(RdapRoute::Asn(asn));
61        }
62    }
63
64    // 3) Fallback: domain lookup.
65    Ok(RdapRoute::Domain(trimmed.to_string()))
66}
67
68/// Auto-routing RDAP lookup.
69///
70/// Classifies `query` with [`classify`] and dispatches to the appropriate
71/// method on the supplied [`RdapClient`]. This replaces the previous
72/// Python-side dispatcher, which used `int()`-style sniffing and would
73/// silently misroute `AS`-prefixed domains like `as1234.io` to the ASN
74/// endpoint.
75pub async fn auto_lookup(client: &RdapClient, query: &str) -> Result<RdapResponse> {
76    match classify(query)? {
77        RdapRoute::Ip(_ip) => client.lookup_ip(query.trim()).await,
78        RdapRoute::Asn(asn) => client.lookup_asn(asn).await,
79        RdapRoute::Domain(domain) => client.lookup_domain(&domain).await,
80    }
81}
82
83/// Returns true if `err` is an RDAP HTTP 404 — the registry's RDAP server
84/// authoritatively reports no such object. For a domain query this is the
85/// strongest available-ness signal there is. Every other RDAP failure
86/// (timeout, 5xx, 429, bootstrap/connection error) means "we don't know",
87/// NOT "available", and must not match.
88///
89/// Matches the message produced by `client::query_rdap_internal`
90/// (`"query failed with status 404 ..."`), looking through both the
91/// `RetryExhausted` wrapper and the multi-candidate "all N ... failed; last
92/// error: ..." wrapper, each of which preserves the original 404 marker.
93pub fn rdap_error_is_404(err: &SeerError) -> bool {
94    match err {
95        SeerError::RdapError(msg) => msg.contains("query failed with status 404"),
96        SeerError::RetryExhausted { last_error, .. } => rdap_error_is_404(last_error),
97        _ => false,
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn classifies_ipv4() {
107        assert!(matches!(classify("8.8.8.8").unwrap(), RdapRoute::Ip(_)));
108        assert!(matches!(classify("1.1.1.1").unwrap(), RdapRoute::Ip(_)));
109    }
110
111    #[test]
112    fn classifies_ipv6() {
113        assert!(matches!(
114            classify("2606:4700:4700::1111").unwrap(),
115            RdapRoute::Ip(_)
116        ));
117    }
118
119    #[test]
120    fn classifies_asn_upper() {
121        assert_eq!(classify("AS1234").unwrap(), RdapRoute::Asn(1234));
122    }
123
124    #[test]
125    fn classifies_asn_lower() {
126        assert_eq!(classify("as1234").unwrap(), RdapRoute::Asn(1234));
127    }
128
129    #[test]
130    fn classifies_asn_mixed_case() {
131        assert_eq!(classify("As15169").unwrap(), RdapRoute::Asn(15169));
132    }
133
134    #[test]
135    fn as_prefix_domain_routes_to_domain() {
136        // Regression: as1234.io is a real domain, not ASN. The `.` in the
137        // query must force the domain route.
138        assert_eq!(
139            classify("as1234.io").unwrap(),
140            RdapRoute::Domain("as1234.io".to_string())
141        );
142        assert_eq!(
143            classify("AS1234.IO").unwrap(),
144            RdapRoute::Domain("AS1234.IO".to_string())
145        );
146    }
147
148    #[test]
149    fn asn_with_trailing_junk_routes_to_domain() {
150        // "AS1234x" is not digits-only after the prefix; fall through to domain.
151        assert_eq!(
152            classify("AS1234x").unwrap(),
153            RdapRoute::Domain("AS1234x".to_string())
154        );
155    }
156
157    #[test]
158    fn bare_as_routes_to_domain() {
159        // "AS" alone has an empty digit tail; not an ASN.
160        assert_eq!(classify("AS").unwrap(), RdapRoute::Domain("AS".to_string()));
161    }
162
163    #[test]
164    fn normal_domain_routes_to_domain() {
165        assert_eq!(
166            classify("example.com").unwrap(),
167            RdapRoute::Domain("example.com".to_string())
168        );
169    }
170
171    #[test]
172    fn trims_whitespace() {
173        assert_eq!(classify("  AS64500  ").unwrap(), RdapRoute::Asn(64500));
174        assert_eq!(
175            classify("  example.com  ").unwrap(),
176            RdapRoute::Domain("example.com".to_string())
177        );
178    }
179
180    #[test]
181    fn empty_query_errors() {
182        assert!(matches!(
183            classify("").unwrap_err(),
184            SeerError::InvalidInput(_)
185        ));
186        assert!(matches!(
187            classify("   ").unwrap_err(),
188            SeerError::InvalidInput(_)
189        ));
190    }
191
192    #[test]
193    fn asn_overflow_errors() {
194        // u32::MAX is 4294967295; anything larger must error.
195        assert!(matches!(
196            classify("AS99999999999999").unwrap_err(),
197            SeerError::InvalidInput(_)
198        ));
199    }
200
201    // --- rdap_error_is_404 -------------------------------------------------
202
203    #[test]
204    fn rdap_error_is_404_matches_canonical_marker() {
205        assert!(rdap_error_is_404(&SeerError::RdapError(
206            "query failed with status 404 Not Found".into()
207        )));
208        assert!(rdap_error_is_404(&SeerError::RdapError(
209            "query failed with status 404".into()
210        )));
211    }
212
213    #[test]
214    fn rdap_error_is_404_rejects_other_statuses_and_kinds() {
215        assert!(!rdap_error_is_404(&SeerError::RdapError(
216            "query failed with status 500 Server Error".into()
217        )));
218        assert!(!rdap_error_is_404(&SeerError::RdapError(
219            "query failed with status 429 Too Many Requests".into()
220        )));
221        assert!(!rdap_error_is_404(&SeerError::RdapBootstrapError(
222            "no RDAP server for example.hk".into()
223        )));
224        assert!(!rdap_error_is_404(&SeerError::Timeout("rdap".into())));
225    }
226
227    #[test]
228    fn rdap_error_is_404_rejects_incidental_digits() {
229        // A "404" buried in an unrelated numeric context must not match.
230        assert!(!rdap_error_is_404(&SeerError::RdapError(
231            "error 40404: database corruption".into()
232        )));
233    }
234
235    #[test]
236    fn rdap_error_is_404_looks_through_retry_exhausted() {
237        let inner = SeerError::RdapError("query failed with status 404 Not Found".into());
238        let wrapped = SeerError::RetryExhausted {
239            attempts: 2,
240            last_error: Box::new(inner),
241        };
242        assert!(rdap_error_is_404(&wrapped));
243    }
244
245    #[test]
246    fn rdap_error_is_404_matches_multi_candidate_wrapper() {
247        // wrap_all_candidates_failed embeds the original marker in its message.
248        let err = SeerError::RdapError(
249            "all 2 RDAP candidate URLs failed; last error: query failed with status 404 Not Found"
250                .into(),
251        );
252        assert!(rdap_error_is_404(&err));
253    }
254}