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("Bulk operation failed: {context}")]
79 BulkOperationError {
80 context: String,
81 failures: Vec<(String, String)>,
82 },
83
84 #[error("Lookup failed for {domain}: {details}\n\nTip: Try checking the registry directly at: {registry_url}")]
85 LookupFailed {
86 domain: String,
87 details: String,
88 registry_url: String,
89 },
90
91 #[error("Configuration error: {0}")]
92 ConfigError(String),
93
94 #[error("Invalid input: {0}")]
95 InvalidInput(String),
96
97 #[error("{0}")]
98 Other(String),
99
100 #[error("Operation failed after {attempts} attempts: {last_error}")]
101 RetryExhausted {
102 attempts: usize,
103 last_error: Box<SeerError>,
104 },
105}
106
107fn is_transient_reqwest_error(error: &reqwest::Error) -> bool {
115 if error.is_connect() {
117 return true;
118 }
119
120 if error.is_timeout() {
122 return true;
123 }
124
125 if let Some(status) = error.status() {
127 if status.as_u16() == 429 {
129 return true;
130 }
131 if status.is_server_error() {
133 return true;
134 }
135 return false;
137 }
138
139 if error.is_request() || error.is_body() {
141 return false;
142 }
143
144 true
146}
147
148impl From<reqwest::Error> for SeerError {
149 fn from(e: reqwest::Error) -> Self {
150 SeerError::ReqwestError {
151 message: e.to_string(),
152 transient: is_transient_reqwest_error(&e),
153 }
154 }
155}
156
157impl From<hickory_resolver::net::NetError> for SeerError {
158 fn from(e: hickory_resolver::net::NetError) -> Self {
159 SeerError::DnsResolverError(e.to_string())
160 }
161}
162
163impl SeerError {
164 pub fn sanitized_message(&self) -> String {
176 match self {
177 SeerError::WhoisError(_) => "WHOIS lookup failed".to_string(),
178 SeerError::WhoisServerNotFound(_) => "WHOIS server not found for this TLD".to_string(),
179 SeerError::WhoisConnectionFailed(_) => "WHOIS connection failed".to_string(),
180 SeerError::RdapError(_) => "RDAP lookup failed".to_string(),
181 SeerError::RdapBootstrapError(_) => {
182 "RDAP service unavailable for this resource".to_string()
183 }
184 SeerError::DnsError(_) => "DNS resolution failed".to_string(),
185 SeerError::DnsResolverError(_) => "DNS resolution failed".to_string(),
186 SeerError::InvalidDomain(domain) => format!("Invalid domain name: {}", domain),
187 SeerError::DomainNotAllowed { tld, .. } => {
188 format!("Domain not allowed: TLD '{}' is not in the allowlist", tld)
189 }
190 SeerError::InvalidIpAddress(ip) => format!("Invalid IP address: {}", ip),
191 SeerError::InvalidRecordType(rt) => format!("Invalid record type: {}", rt),
192 SeerError::HttpError(_) => "HTTP request failed".to_string(),
193 SeerError::ReqwestError { .. } => "HTTP request failed".to_string(),
194 SeerError::JsonError(_) => "Response parsing failed".to_string(),
195 SeerError::Timeout(_) => "Operation timed out".to_string(),
196 SeerError::RateLimited(_) => "Rate limited - please try again later".to_string(),
197 SeerError::CertificateError(_) => "Certificate validation failed".to_string(),
198 SeerError::SslError(_) => "SSL inspection failed".to_string(),
199 SeerError::BulkOperationError { context, .. } => {
200 format!("Bulk operation partially failed: {}", context)
201 }
202 SeerError::LookupFailed { domain, .. } => format!("Lookup failed for {}", domain),
204 SeerError::ConfigError(_) => "Configuration error".to_string(),
205 SeerError::InvalidInput(msg) => format!("Invalid input: {}", msg),
209 SeerError::Other(_) => "Operation failed".to_string(),
210 SeerError::RetryExhausted {
211 attempts,
212 last_error,
213 } => {
214 format!(
215 "Operation failed after {} attempts: {}",
216 attempts,
217 last_error.sanitized_message()
218 )
219 }
220 }
221 }
222}
223
224pub type Result<T> = std::result::Result<T, SeerError>;
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn transport_errors_sanitized_to_category_only() {
232 let cases: &[(SeerError, &str, &str)] = &[
237 (
238 SeerError::HttpError("CT log query failed: 502 Bad Gateway".into()),
239 "HTTP request failed",
240 "502",
241 ),
242 (
243 SeerError::Timeout("connection to whois.example.com timed out".into()),
244 "Operation timed out",
245 "whois.example.com",
246 ),
247 (
248 SeerError::DnsError("invalid nameserver IP: 10.1.2.3".into()),
249 "DNS resolution failed",
250 "10.1.2.3",
251 ),
252 (
253 SeerError::SslError("could not resolve internal.host for SSL: refused".into()),
254 "SSL inspection failed",
255 "internal.host",
256 ),
257 (
258 SeerError::WhoisConnectionFailed("connect to whois.nic.internal:43 refused".into()),
259 "WHOIS connection failed",
260 "internal",
261 ),
262 (
263 SeerError::ConfigError("/home/user/.seer/secret.toml unreadable".into()),
264 "Configuration error",
265 ".seer",
266 ),
267 ];
268 for (err, category, leak) in cases {
269 let msg = err.sanitized_message();
270 assert!(
271 msg.contains(category),
272 "expected category {category}; got: {msg}"
273 );
274 assert!(
275 !msg.contains(leak),
276 "detail '{leak}' must NOT leak into sanitized message; got: {msg}"
277 );
278 }
279 }
280
281 #[test]
282 fn user_input_echoes_are_kept() {
283 assert!(SeerError::InvalidDomain("bad_domain".into())
285 .sanitized_message()
286 .contains("bad_domain"));
287 assert!(SeerError::InvalidRecordType("ZZZ".into())
288 .sanitized_message()
289 .contains("ZZZ"));
290 }
291
292 #[tokio::test]
295 async fn reqwest_status_errors_classified_at_conversion() {
296 use wiremock::matchers::{method, path};
297 use wiremock::{Mock, MockServer, ResponseTemplate};
298
299 let server = MockServer::start().await;
300 let cases: &[(u16, bool)] = &[
301 (429, true), (500, true), (503, true),
304 (400, false), (404, false),
306 ];
307 for (status, _) in cases {
308 Mock::given(method("GET"))
309 .and(path(format!("/s{status}")))
310 .respond_with(ResponseTemplate::new(*status))
311 .mount(&server)
312 .await;
313 }
314
315 let client = reqwest::Client::new();
316 for (status, expect_transient) in cases {
317 let reqwest_err = client
318 .get(format!("{}/s{}", server.uri(), status))
319 .send()
320 .await
321 .expect("mock server is reachable")
322 .error_for_status()
323 .expect_err("status is an error");
324 match SeerError::from(reqwest_err) {
325 SeerError::ReqwestError { message, transient } => {
326 assert_eq!(
327 transient, *expect_transient,
328 "status {status} transiency misclassified"
329 );
330 assert!(
332 message.contains(&status.to_string()),
333 "status {status} missing from message: {message}"
334 );
335 }
336 other => panic!("expected ReqwestError, got {other:?}"),
337 }
338 }
339 }
340
341 #[tokio::test]
342 async fn reqwest_connect_error_classified_transient_at_conversion() {
343 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
346 .await
347 .expect("bind loopback");
348 let port = listener.local_addr().expect("local addr").port();
349 drop(listener);
350
351 let client = reqwest::Client::builder()
352 .timeout(std::time::Duration::from_secs(5))
353 .build()
354 .expect("client builds");
355 let reqwest_err = client
356 .get(format!("http://127.0.0.1:{port}/"))
357 .send()
358 .await
359 .expect_err("connect must fail");
360 match SeerError::from(reqwest_err) {
361 SeerError::ReqwestError { transient, .. } => {
362 assert!(transient, "connect failures must classify as transient");
363 }
364 other => panic!("expected ReqwestError, got {other:?}"),
365 }
366 }
367
368 #[test]
369 fn dns_resolver_error_converts_to_owned_message() {
370 let err: SeerError = hickory_resolver::net::NetError::Timeout.into();
373 match &err {
374 SeerError::DnsResolverError(msg) => {
375 assert!(msg.contains("timed out"), "detail must survive: {msg}");
376 }
377 other => panic!("expected DnsResolverError, got {other:?}"),
378 }
379 assert_eq!(err.to_string(), "DNS resolver error: request timed out");
380 }
381}