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#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum RdapRoute {
18 Ip(IpAddr),
20 Asn(u32),
22 Domain(String),
24}
25
26pub 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 if let Ok(ip) = trimmed.parse::<IpAddr>() {
48 return Ok(RdapRoute::Ip(ip));
49 }
50
51 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 Ok(RdapRoute::Domain(trimmed.to_string()))
66}
67
68pub 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
83pub 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 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 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 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 assert!(matches!(
196 classify("AS99999999999999").unwrap_err(),
197 SeerError::InvalidInput(_)
198 ));
199 }
200
201 #[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 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 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}