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#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn classifies_ipv4() {
89        assert!(matches!(classify("8.8.8.8").unwrap(), RdapRoute::Ip(_)));
90        assert!(matches!(classify("1.1.1.1").unwrap(), RdapRoute::Ip(_)));
91    }
92
93    #[test]
94    fn classifies_ipv6() {
95        assert!(matches!(
96            classify("2606:4700:4700::1111").unwrap(),
97            RdapRoute::Ip(_)
98        ));
99    }
100
101    #[test]
102    fn classifies_asn_upper() {
103        assert_eq!(classify("AS1234").unwrap(), RdapRoute::Asn(1234));
104    }
105
106    #[test]
107    fn classifies_asn_lower() {
108        assert_eq!(classify("as1234").unwrap(), RdapRoute::Asn(1234));
109    }
110
111    #[test]
112    fn classifies_asn_mixed_case() {
113        assert_eq!(classify("As15169").unwrap(), RdapRoute::Asn(15169));
114    }
115
116    #[test]
117    fn as_prefix_domain_routes_to_domain() {
118        // Regression: as1234.io is a real domain, not ASN. The `.` in the
119        // query must force the domain route.
120        assert_eq!(
121            classify("as1234.io").unwrap(),
122            RdapRoute::Domain("as1234.io".to_string())
123        );
124        assert_eq!(
125            classify("AS1234.IO").unwrap(),
126            RdapRoute::Domain("AS1234.IO".to_string())
127        );
128    }
129
130    #[test]
131    fn asn_with_trailing_junk_routes_to_domain() {
132        // "AS1234x" is not digits-only after the prefix; fall through to domain.
133        assert_eq!(
134            classify("AS1234x").unwrap(),
135            RdapRoute::Domain("AS1234x".to_string())
136        );
137    }
138
139    #[test]
140    fn bare_as_routes_to_domain() {
141        // "AS" alone has an empty digit tail; not an ASN.
142        assert_eq!(classify("AS").unwrap(), RdapRoute::Domain("AS".to_string()));
143    }
144
145    #[test]
146    fn normal_domain_routes_to_domain() {
147        assert_eq!(
148            classify("example.com").unwrap(),
149            RdapRoute::Domain("example.com".to_string())
150        );
151    }
152
153    #[test]
154    fn trims_whitespace() {
155        assert_eq!(classify("  AS64500  ").unwrap(), RdapRoute::Asn(64500));
156        assert_eq!(
157            classify("  example.com  ").unwrap(),
158            RdapRoute::Domain("example.com".to_string())
159        );
160    }
161
162    #[test]
163    fn empty_query_errors() {
164        assert!(matches!(
165            classify("").unwrap_err(),
166            SeerError::InvalidInput(_)
167        ));
168        assert!(matches!(
169            classify("   ").unwrap_err(),
170            SeerError::InvalidInput(_)
171        ));
172    }
173
174    #[test]
175    fn asn_overflow_errors() {
176        // u32::MAX is 4294967295; anything larger must error.
177        assert!(matches!(
178            classify("AS99999999999999").unwrap_err(),
179            SeerError::InvalidInput(_)
180        ));
181    }
182}