1mod bootstrap;
12mod client;
13mod types;
14
15pub use client::RdapClient;
16#[cfg(feature = "cli")]
18pub(crate) use client::{IANA_BOOTSTRAP_DNS, MAX_BOOTSTRAP_SIZE};
19pub use types::{ContactInfo, RdapResponse, RegistrarDetail};
20
21use std::net::IpAddr;
22
23use crate::error::{Result, SeerError};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum RdapRoute {
31 Ip(IpAddr),
33 Asn(u32),
35 Domain(String),
37}
38
39pub fn classify(query: &str) -> Result<RdapRoute> {
54 let trimmed = query.trim();
55 if trimmed.is_empty() {
56 return Err(SeerError::InvalidInput("empty RDAP query".to_string()));
57 }
58
59 if let Ok(ip) = trimmed.parse::<IpAddr>() {
61 return Ok(RdapRoute::Ip(ip));
62 }
63
64 let upper = trimmed.to_uppercase();
68 if let Some(rest) = upper.strip_prefix("AS") {
69 if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) && !trimmed.contains('.') {
70 let asn: u32 = rest
71 .parse()
72 .map_err(|_| SeerError::InvalidInput(format!("invalid ASN: {query}")))?;
73 return Ok(RdapRoute::Asn(asn));
74 }
75 }
76
77 Ok(RdapRoute::Domain(trimmed.to_string()))
79}
80
81pub async fn auto_lookup(client: &RdapClient, query: &str) -> Result<RdapResponse> {
89 match classify(query)? {
90 RdapRoute::Ip(_ip) => client.lookup_ip(query.trim()).await,
91 RdapRoute::Asn(asn) => client.lookup_asn(asn).await,
92 RdapRoute::Domain(domain) => client.lookup_domain(&domain).await,
93 }
94}
95
96pub fn rdap_error_is_404(err: &SeerError) -> bool {
107 match err {
108 SeerError::RdapError(msg) => msg.contains("query failed with status 404"),
109 SeerError::RetryExhausted { last_error, .. } => rdap_error_is_404(last_error),
110 _ => false,
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn classifies_ipv4() {
120 assert!(matches!(classify("8.8.8.8").unwrap(), RdapRoute::Ip(_)));
121 assert!(matches!(classify("1.1.1.1").unwrap(), RdapRoute::Ip(_)));
122 }
123
124 #[test]
125 fn classifies_ipv6() {
126 assert!(matches!(
127 classify("2606:4700:4700::1111").unwrap(),
128 RdapRoute::Ip(_)
129 ));
130 }
131
132 #[test]
133 fn classifies_asn_upper() {
134 assert_eq!(classify("AS1234").unwrap(), RdapRoute::Asn(1234));
135 }
136
137 #[test]
138 fn classifies_asn_lower() {
139 assert_eq!(classify("as1234").unwrap(), RdapRoute::Asn(1234));
140 }
141
142 #[test]
143 fn classifies_asn_mixed_case() {
144 assert_eq!(classify("As15169").unwrap(), RdapRoute::Asn(15169));
145 }
146
147 #[test]
148 fn as_prefix_domain_routes_to_domain() {
149 assert_eq!(
152 classify("as1234.io").unwrap(),
153 RdapRoute::Domain("as1234.io".to_string())
154 );
155 assert_eq!(
156 classify("AS1234.IO").unwrap(),
157 RdapRoute::Domain("AS1234.IO".to_string())
158 );
159 }
160
161 #[test]
162 fn asn_with_trailing_junk_routes_to_domain() {
163 assert_eq!(
165 classify("AS1234x").unwrap(),
166 RdapRoute::Domain("AS1234x".to_string())
167 );
168 }
169
170 #[test]
171 fn bare_as_routes_to_domain() {
172 assert_eq!(classify("AS").unwrap(), RdapRoute::Domain("AS".to_string()));
174 }
175
176 #[test]
177 fn normal_domain_routes_to_domain() {
178 assert_eq!(
179 classify("example.com").unwrap(),
180 RdapRoute::Domain("example.com".to_string())
181 );
182 }
183
184 #[test]
185 fn trims_whitespace() {
186 assert_eq!(classify(" AS64500 ").unwrap(), RdapRoute::Asn(64500));
187 assert_eq!(
188 classify(" example.com ").unwrap(),
189 RdapRoute::Domain("example.com".to_string())
190 );
191 }
192
193 #[test]
194 fn empty_query_errors() {
195 assert!(matches!(
196 classify("").unwrap_err(),
197 SeerError::InvalidInput(_)
198 ));
199 assert!(matches!(
200 classify(" ").unwrap_err(),
201 SeerError::InvalidInput(_)
202 ));
203 }
204
205 #[test]
206 fn asn_overflow_errors() {
207 assert!(matches!(
209 classify("AS99999999999999").unwrap_err(),
210 SeerError::InvalidInput(_)
211 ));
212 }
213
214 #[test]
217 fn rdap_error_is_404_matches_canonical_marker() {
218 assert!(rdap_error_is_404(&SeerError::RdapError(
219 "query failed with status 404 Not Found".into()
220 )));
221 assert!(rdap_error_is_404(&SeerError::RdapError(
222 "query failed with status 404".into()
223 )));
224 }
225
226 #[test]
227 fn rdap_error_is_404_rejects_other_statuses_and_kinds() {
228 assert!(!rdap_error_is_404(&SeerError::RdapError(
229 "query failed with status 500 Server Error".into()
230 )));
231 assert!(!rdap_error_is_404(&SeerError::RdapError(
232 "query failed with status 429 Too Many Requests".into()
233 )));
234 assert!(!rdap_error_is_404(&SeerError::RdapBootstrapError(
235 "no RDAP server for example.hk".into()
236 )));
237 assert!(!rdap_error_is_404(&SeerError::Timeout("rdap".into())));
238 }
239
240 #[test]
241 fn rdap_error_is_404_rejects_incidental_digits() {
242 assert!(!rdap_error_is_404(&SeerError::RdapError(
244 "error 40404: database corruption".into()
245 )));
246 }
247
248 #[test]
249 fn rdap_error_is_404_looks_through_retry_exhausted() {
250 let inner = SeerError::RdapError("query failed with status 404 Not Found".into());
251 let wrapped = SeerError::RetryExhausted {
252 attempts: 2,
253 last_error: Box::new(inner),
254 };
255 assert!(rdap_error_is_404(&wrapped));
256 }
257
258 #[test]
259 fn rdap_error_is_404_matches_multi_candidate_wrapper() {
260 let err = SeerError::RdapError(
262 "all 2 RDAP candidate URLs failed; last error: query failed with status 404 Not Found"
263 .into(),
264 );
265 assert!(rdap_error_is_404(&err));
266 }
267}