1use thiserror::Error;
2
3#[derive(Error, Debug)]
11#[non_exhaustive]
12pub enum SeerError {
13 #[error("WHOIS lookup failed: {0}")]
14 WhoisError(String),
15
16 #[error("WHOIS server not found for TLD: {0}")]
17 WhoisServerNotFound(String),
18
19 #[error("WHOIS connection failed: {0}")]
20 WhoisConnectionFailed(String),
21
22 #[error("RDAP lookup failed: {0}")]
23 RdapError(String),
24
25 #[error("RDAP bootstrap failed: {0}")]
26 RdapBootstrapError(String),
27
28 #[error("DNS resolution failed: {0}")]
29 DnsError(String),
30
31 #[error("DNS resolver error: {0}")]
34 DnsResolverError(String),
35
36 #[error("Invalid domain name: {0}")]
37 InvalidDomain(String),
38
39 #[error("Domain not allowed: TLD '{tld}' is not in the allowlist")]
40 DomainNotAllowed { domain: String, tld: String },
41
42 #[error("Invalid IP address: {0}")]
43 InvalidIpAddress(String),
44
45 #[error("Invalid record type: {0}")]
46 InvalidRecordType(String),
47
48 #[error("HTTP request failed: {0}")]
49 HttpError(String),
50
51 #[error("Reqwest error: {message}")]
54 ReqwestError {
55 message: String,
57 transient: bool,
61 },
62
63 #[error("JSON parsing failed: {0}")]
64 JsonError(#[from] serde_json::Error),
65
66 #[error("Timeout: {0}")]
67 Timeout(String),
68
69 #[error("Rate limited: {0}")]
70 RateLimited(String),
71
72 #[error("Certificate error: {0}")]
73 CertificateError(String),
74
75 #[error("SSL error: {0}")]
76 SslError(String),
77
78 #[error("Lookup failed for {domain}: {details}\n\nTip: Try checking the registry directly at: {registry_url}")]
79 LookupFailed {
80 domain: String,
81 details: String,
82 registry_url: String,
83 },
84
85 #[error("Configuration error: {0}")]
86 ConfigError(String),
87
88 #[error("Invalid input: {0}")]
89 InvalidInput(String),
90
91 #[error("{0}")]
92 Other(String),
93
94 #[error("Operation failed after {attempts} attempts: {last_error}")]
95 RetryExhausted {
96 attempts: usize,
97 last_error: Box<SeerError>,
98 },
99}
100
101fn is_transient_reqwest_error(error: &reqwest::Error) -> bool {
109 if error.is_connect() {
111 return true;
112 }
113
114 if error.is_timeout() {
116 return true;
117 }
118
119 if let Some(status) = error.status() {
121 if status.as_u16() == 429 {
123 return true;
124 }
125 if status.is_server_error() {
127 return true;
128 }
129 return false;
131 }
132
133 if error.is_request() || error.is_body() {
135 return false;
136 }
137
138 true
140}
141
142impl From<reqwest::Error> for SeerError {
143 fn from(e: reqwest::Error) -> Self {
144 SeerError::ReqwestError {
145 message: e.to_string(),
146 transient: is_transient_reqwest_error(&e),
147 }
148 }
149}
150
151impl From<hickory_resolver::net::NetError> for SeerError {
152 fn from(e: hickory_resolver::net::NetError) -> Self {
153 SeerError::DnsResolverError(e.to_string())
154 }
155}
156
157impl SeerError {
158 pub fn sanitized_message(&self) -> String {
170 match self {
171 SeerError::WhoisError(_) => "WHOIS lookup failed".to_string(),
172 SeerError::WhoisServerNotFound(_) => "WHOIS server not found for this TLD".to_string(),
173 SeerError::WhoisConnectionFailed(_) => "WHOIS connection failed".to_string(),
174 SeerError::RdapError(_) => "RDAP lookup failed".to_string(),
175 SeerError::RdapBootstrapError(_) => {
176 "RDAP service unavailable for this resource".to_string()
177 }
178 SeerError::DnsError(_) => "DNS resolution failed".to_string(),
179 SeerError::DnsResolverError(_) => "DNS resolution failed".to_string(),
180 SeerError::InvalidDomain(domain) => format!("Invalid domain name: {}", domain),
181 SeerError::DomainNotAllowed { tld, .. } => {
182 format!("Domain not allowed: TLD '{}' is not in the allowlist", tld)
183 }
184 SeerError::InvalidIpAddress(ip) => format!("Invalid IP address: {}", ip),
185 SeerError::InvalidRecordType(rt) => format!("Invalid record type: {}", rt),
186 SeerError::HttpError(_) => "HTTP request failed".to_string(),
187 SeerError::ReqwestError { .. } => "HTTP request failed".to_string(),
188 SeerError::JsonError(_) => "Response parsing failed".to_string(),
189 SeerError::Timeout(_) => "Operation timed out".to_string(),
190 SeerError::RateLimited(_) => "Rate limited - please try again later".to_string(),
191 SeerError::CertificateError(_) => "Certificate validation failed".to_string(),
192 SeerError::SslError(_) => "SSL inspection failed".to_string(),
193 SeerError::LookupFailed { domain, .. } => format!("Lookup failed for {}", domain),
195 SeerError::ConfigError(_) => "Configuration error".to_string(),
196 SeerError::InvalidInput(msg) => format!("Invalid input: {}", msg),
200 SeerError::Other(_) => "Operation failed".to_string(),
201 SeerError::RetryExhausted {
202 attempts,
203 last_error,
204 } => {
205 format!(
206 "Operation failed after {} attempts: {}",
207 attempts,
208 last_error.sanitized_message()
209 )
210 }
211 }
212 }
213}
214
215pub type Result<T> = std::result::Result<T, SeerError>;
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn transport_errors_sanitized_to_category_only() {
223 let cases: &[(SeerError, &str, &str)] = &[
228 (
229 SeerError::HttpError("CT log query failed: 502 Bad Gateway".into()),
230 "HTTP request failed",
231 "502",
232 ),
233 (
234 SeerError::Timeout("connection to whois.example.com timed out".into()),
235 "Operation timed out",
236 "whois.example.com",
237 ),
238 (
239 SeerError::DnsError("invalid nameserver IP: 10.1.2.3".into()),
240 "DNS resolution failed",
241 "10.1.2.3",
242 ),
243 (
244 SeerError::SslError("could not resolve internal.host for SSL: refused".into()),
245 "SSL inspection failed",
246 "internal.host",
247 ),
248 (
249 SeerError::WhoisConnectionFailed("connect to whois.nic.internal:43 refused".into()),
250 "WHOIS connection failed",
251 "internal",
252 ),
253 (
254 SeerError::ConfigError("/home/user/.seer/secret.toml unreadable".into()),
255 "Configuration error",
256 ".seer",
257 ),
258 ];
259 for (err, category, leak) in cases {
260 let msg = err.sanitized_message();
261 assert!(
262 msg.contains(category),
263 "expected category {category}; got: {msg}"
264 );
265 assert!(
266 !msg.contains(leak),
267 "detail '{leak}' must NOT leak into sanitized message; got: {msg}"
268 );
269 }
270 }
271
272 #[test]
273 fn user_input_echoes_are_kept() {
274 assert!(SeerError::InvalidDomain("bad_domain".into())
276 .sanitized_message()
277 .contains("bad_domain"));
278 assert!(SeerError::InvalidRecordType("ZZZ".into())
279 .sanitized_message()
280 .contains("ZZZ"));
281 }
282
283 #[tokio::test]
286 async fn reqwest_status_errors_classified_at_conversion() {
287 use wiremock::matchers::{method, path};
288 use wiremock::{Mock, MockServer, ResponseTemplate};
289
290 let server = MockServer::start().await;
291 let cases: &[(u16, bool)] = &[
292 (429, true), (500, true), (503, true),
295 (400, false), (404, false),
297 ];
298 for (status, _) in cases {
299 Mock::given(method("GET"))
300 .and(path(format!("/s{status}")))
301 .respond_with(ResponseTemplate::new(*status))
302 .mount(&server)
303 .await;
304 }
305
306 let client = reqwest::Client::new();
307 for (status, expect_transient) in cases {
308 let reqwest_err = client
309 .get(format!("{}/s{}", server.uri(), status))
310 .send()
311 .await
312 .expect("mock server is reachable")
313 .error_for_status()
314 .expect_err("status is an error");
315 match SeerError::from(reqwest_err) {
316 SeerError::ReqwestError { message, transient } => {
317 assert_eq!(
318 transient, *expect_transient,
319 "status {status} transiency misclassified"
320 );
321 assert!(
323 message.contains(&status.to_string()),
324 "status {status} missing from message: {message}"
325 );
326 }
327 other => panic!("expected ReqwestError, got {other:?}"),
328 }
329 }
330 }
331
332 #[tokio::test]
333 async fn reqwest_connect_error_classified_transient_at_conversion() {
334 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
337 .await
338 .expect("bind loopback");
339 let port = listener.local_addr().expect("local addr").port();
340 drop(listener);
341
342 let client = reqwest::Client::builder()
343 .timeout(std::time::Duration::from_secs(5))
344 .build()
345 .expect("client builds");
346 let reqwest_err = client
347 .get(format!("http://127.0.0.1:{port}/"))
348 .send()
349 .await
350 .expect_err("connect must fail");
351 match SeerError::from(reqwest_err) {
352 SeerError::ReqwestError { transient, .. } => {
353 assert!(transient, "connect failures must classify as transient");
354 }
355 other => panic!("expected ReqwestError, got {other:?}"),
356 }
357 }
358
359 #[test]
360 fn dns_resolver_error_converts_to_owned_message() {
361 let err: SeerError = hickory_resolver::net::NetError::Timeout.into();
364 match &err {
365 SeerError::DnsResolverError(msg) => {
366 assert!(msg.contains("timed out"), "detail must survive: {msg}");
367 }
368 other => panic!("expected DnsResolverError, got {other:?}"),
369 }
370 assert_eq!(err.to_string(), "DNS resolver error: request timed out");
371 }
372}