1use thiserror::Error;
2
3#[derive(Error, Debug)]
4pub enum SeerError {
5 #[error("WHOIS lookup failed: {0}")]
6 WhoisError(String),
7
8 #[error("WHOIS server not found for TLD: {0}")]
9 WhoisServerNotFound(String),
10
11 #[error("WHOIS connection failed: {0}")]
12 WhoisConnectionFailed(String),
13
14 #[error("RDAP lookup failed: {0}")]
15 RdapError(String),
16
17 #[error("RDAP bootstrap failed: {0}")]
18 RdapBootstrapError(String),
19
20 #[error("DNS resolution failed: {0}")]
21 DnsError(String),
22
23 #[error("DNS resolver error: {0}")]
24 DnsResolverError(#[from] hickory_resolver::net::NetError),
25
26 #[error("Invalid domain name: {0}")]
27 InvalidDomain(String),
28
29 #[error("Domain not allowed: TLD '{tld}' is not in the allowlist")]
30 DomainNotAllowed { domain: String, tld: String },
31
32 #[error("Invalid IP address: {0}")]
33 InvalidIpAddress(String),
34
35 #[error("Invalid record type: {0}")]
36 InvalidRecordType(String),
37
38 #[error("HTTP request failed: {0}")]
39 HttpError(String),
40
41 #[error("Reqwest error: {0}")]
42 ReqwestError(#[from] reqwest::Error),
43
44 #[error("JSON parsing failed: {0}")]
45 JsonError(#[from] serde_json::Error),
46
47 #[error("Timeout: {0}")]
48 Timeout(String),
49
50 #[error("Rate limited: {0}")]
51 RateLimited(String),
52
53 #[error("Certificate error: {0}")]
54 CertificateError(String),
55
56 #[error("SSL error: {0}")]
57 SslError(String),
58
59 #[error("Bulk operation failed: {context}")]
60 BulkOperationError {
61 context: String,
62 failures: Vec<(String, String)>,
63 },
64
65 #[error("Lookup failed for {domain}: {details}\n\nTip: Try checking the registry directly at: {registry_url}")]
66 LookupFailed {
67 domain: String,
68 details: String,
69 registry_url: String,
70 },
71
72 #[error("Configuration error: {0}")]
73 ConfigError(String),
74
75 #[error("Invalid input: {0}")]
76 InvalidInput(String),
77
78 #[error("{0}")]
79 Other(String),
80
81 #[error("Operation failed after {attempts} attempts: {last_error}")]
82 RetryExhausted {
83 attempts: usize,
84 last_error: Box<SeerError>,
85 },
86}
87
88impl SeerError {
89 pub fn sanitized_message(&self) -> String {
101 match self {
102 SeerError::WhoisError(_) => "WHOIS lookup failed".to_string(),
103 SeerError::WhoisServerNotFound(_) => "WHOIS server not found for this TLD".to_string(),
104 SeerError::WhoisConnectionFailed(_) => "WHOIS connection failed".to_string(),
105 SeerError::RdapError(_) => "RDAP lookup failed".to_string(),
106 SeerError::RdapBootstrapError(_) => {
107 "RDAP service unavailable for this resource".to_string()
108 }
109 SeerError::DnsError(_) => "DNS resolution failed".to_string(),
110 SeerError::DnsResolverError(_) => "DNS resolution failed".to_string(),
111 SeerError::InvalidDomain(domain) => format!("Invalid domain name: {}", domain),
112 SeerError::DomainNotAllowed { tld, .. } => {
113 format!("Domain not allowed: TLD '{}' is not in the allowlist", tld)
114 }
115 SeerError::InvalidIpAddress(ip) => format!("Invalid IP address: {}", ip),
116 SeerError::InvalidRecordType(rt) => format!("Invalid record type: {}", rt),
117 SeerError::HttpError(_) => "HTTP request failed".to_string(),
118 SeerError::ReqwestError(_) => "HTTP request failed".to_string(),
119 SeerError::JsonError(_) => "Response parsing failed".to_string(),
120 SeerError::Timeout(_) => "Operation timed out".to_string(),
121 SeerError::RateLimited(_) => "Rate limited - please try again later".to_string(),
122 SeerError::CertificateError(_) => "Certificate validation failed".to_string(),
123 SeerError::SslError(_) => "SSL inspection failed".to_string(),
124 SeerError::BulkOperationError { context, .. } => {
125 format!("Bulk operation partially failed: {}", context)
126 }
127 SeerError::LookupFailed { domain, .. } => format!("Lookup failed for {}", domain),
129 SeerError::ConfigError(_) => "Configuration error".to_string(),
130 SeerError::InvalidInput(msg) => format!("Invalid input: {}", msg),
134 SeerError::Other(_) => "Operation failed".to_string(),
135 SeerError::RetryExhausted {
136 attempts,
137 last_error,
138 } => {
139 format!(
140 "Operation failed after {} attempts: {}",
141 attempts,
142 last_error.sanitized_message()
143 )
144 }
145 }
146 }
147}
148
149pub type Result<T> = std::result::Result<T, SeerError>;
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn transport_errors_sanitized_to_category_only() {
157 let cases: &[(SeerError, &str, &str)] = &[
162 (
163 SeerError::HttpError("CT log query failed: 502 Bad Gateway".into()),
164 "HTTP request failed",
165 "502",
166 ),
167 (
168 SeerError::Timeout("connection to whois.example.com timed out".into()),
169 "Operation timed out",
170 "whois.example.com",
171 ),
172 (
173 SeerError::DnsError("invalid nameserver IP: 10.1.2.3".into()),
174 "DNS resolution failed",
175 "10.1.2.3",
176 ),
177 (
178 SeerError::SslError("could not resolve internal.host for SSL: refused".into()),
179 "SSL inspection failed",
180 "internal.host",
181 ),
182 (
183 SeerError::WhoisConnectionFailed("connect to whois.nic.internal:43 refused".into()),
184 "WHOIS connection failed",
185 "internal",
186 ),
187 (
188 SeerError::ConfigError("/home/user/.seer/secret.toml unreadable".into()),
189 "Configuration error",
190 ".seer",
191 ),
192 ];
193 for (err, category, leak) in cases {
194 let msg = err.sanitized_message();
195 assert!(
196 msg.contains(category),
197 "expected category {category}; got: {msg}"
198 );
199 assert!(
200 !msg.contains(leak),
201 "detail '{leak}' must NOT leak into sanitized message; got: {msg}"
202 );
203 }
204 }
205
206 #[test]
207 fn user_input_echoes_are_kept() {
208 assert!(SeerError::InvalidDomain("bad_domain".into())
210 .sanitized_message()
211 .contains("bad_domain"));
212 assert!(SeerError::InvalidRecordType("ZZZ".into())
213 .sanitized_message()
214 .contains("ZZZ"));
215 }
216}