Skip to main content

seer_core/
error.rs

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    /// Returns a sanitized error message safe for external exposure.
90    /// This hides internal details like server hostnames and raw system errors.
91    pub fn sanitized_message(&self) -> String {
92        match self {
93            // Detail is preserved across these variants for the same reasons
94            // as the earlier SslError / DnsError / WhoisServerNotFound fixes:
95            // Python callers and composite tools need to distinguish e.g. a
96            // 502 from crt.sh, a connect-refused, a Reqwest URL parse error,
97            // or a CT-log JSON deserialization failure — collapsing them all
98            // to generic strings makes every transient blip look identical
99            // and forces the caller to grep tracing logs. For a domain-
100            // intelligence tool that hits public APIs, there is no internal
101            // infrastructure detail worth hiding here; the underlying error
102            // strings are constructed by us or by reqwest's Display, which
103            // only reveals public URLs and standard transport errors.
104            SeerError::WhoisError(detail) => format!("WHOIS lookup failed: {}", detail),
105            SeerError::WhoisServerNotFound(detail) => {
106                format!("WHOIS server not found for this TLD: {}", detail)
107            }
108            SeerError::WhoisConnectionFailed(detail) => {
109                format!("WHOIS connection failed: {}", detail)
110            }
111            SeerError::RdapError(detail) => format!("RDAP lookup failed: {}", detail),
112            SeerError::RdapBootstrapError(detail) => {
113                format!("RDAP service unavailable for this resource: {}", detail)
114            }
115            SeerError::DnsError(detail) => format!("DNS resolution failed: {}", detail),
116            SeerError::DnsResolverError(detail) => format!("DNS resolution failed: {}", detail),
117            SeerError::InvalidDomain(domain) => format!("Invalid domain name: {}", domain),
118            SeerError::DomainNotAllowed { tld, .. } => {
119                format!("Domain not allowed: TLD '{}' is not in the allowlist", tld)
120            }
121            SeerError::InvalidIpAddress(ip) => format!("Invalid IP address: {}", ip),
122            SeerError::InvalidRecordType(rt) => format!("Invalid record type: {}", rt),
123            SeerError::HttpError(detail) => format!("HTTP request failed: {}", detail),
124            SeerError::ReqwestError(detail) => format!("HTTP request failed: {}", detail),
125            SeerError::JsonError(detail) => format!("Response parsing failed: {}", detail),
126            SeerError::Timeout(detail) => format!("Operation timed out: {}", detail),
127            SeerError::RateLimited(detail) => {
128                format!("Rate limited - please try again later: {}", detail)
129            }
130            SeerError::CertificateError(detail) => {
131                format!("Certificate validation failed: {}", detail)
132            }
133            SeerError::SslError(detail) => format!("SSL inspection failed: {}", detail),
134            SeerError::BulkOperationError { context, .. } => {
135                format!("Bulk operation partially failed: {}", context)
136            }
137            SeerError::LookupFailed {
138                domain, details, ..
139            } => {
140                format!("Lookup failed for {}: {}", domain, details)
141            }
142            SeerError::ConfigError(msg) => format!("Configuration error: {}", msg),
143            SeerError::InvalidInput(msg) => format!("Invalid input: {}", msg),
144            SeerError::Other(detail) => format!("Operation failed: {}", detail),
145            SeerError::RetryExhausted {
146                attempts,
147                last_error,
148            } => {
149                format!(
150                    "Operation failed after {} attempts: {}",
151                    attempts,
152                    last_error.sanitized_message()
153                )
154            }
155        }
156    }
157}
158
159pub type Result<T> = std::result::Result<T, SeerError>;
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn http_error_sanitized_includes_detail() {
167        // Regression: HttpError used to collapse to the generic string
168        // "HTTP request failed", which made every transient crt.sh blip,
169        // every reqwest connect-refused, and every JSON deserialization
170        // failure look identical from Python.
171        let err = SeerError::HttpError("CT log query failed: 502 Bad Gateway".into());
172        let msg = err.sanitized_message();
173        assert!(
174            msg.contains("HTTP request failed"),
175            "expected category prefix; got: {msg}"
176        );
177        assert!(
178            msg.contains("502 Bad Gateway"),
179            "expected detail preserved; got: {msg}"
180        );
181    }
182
183    #[test]
184    fn timeout_sanitized_includes_detail() {
185        // Timeout used to collapse to "Operation timed out" — useful as a
186        // category but unactionable. With detail preserved, callers can
187        // see WHICH operation timed out (whois, rdap, ssl probe, dig, etc.)
188        // without grepping tracing logs.
189        let err = SeerError::Timeout("connection to whois.example.com timed out".into());
190        let msg = err.sanitized_message();
191        assert!(msg.contains("Operation timed out"), "got: {msg}");
192        assert!(msg.contains("whois.example.com"), "got: {msg}");
193    }
194
195    #[test]
196    fn dns_error_sanitized_includes_detail() {
197        // Regression: prior to this, DnsError(_) was collapsed to the
198        // generic string "DNS resolution failed", swallowing the reason.
199        // Callers — especially Python wrappers — need the detail to
200        // distinguish "invalid nameserver", "record type not implemented",
201        // "NXDOMAIN", "hostname did not resolve", and friends. Same fix
202        // shape as the earlier SslError detail-preservation fix.
203        let err = SeerError::DnsError("invalid nameserver IP: foo.example".into());
204        let msg = err.sanitized_message();
205        assert!(
206            msg.contains("DNS resolution failed"),
207            "expected category prefix; got: {msg}"
208        );
209        assert!(
210            msg.contains("invalid nameserver IP"),
211            "expected detail to be preserved; got: {msg}"
212        );
213    }
214
215    #[test]
216    fn ssl_error_sanitized_includes_detail() {
217        // Regression: prior to this, SslError(_) was collapsed to the
218        // generic string "SSL inspection failed", swallowing the reason
219        // (DNS failure, handshake refused, no cert presented, etc.).
220        // Callers — especially Python wrappers — need the detail to
221        // distinguish "probe failed" from "certificate genuinely missing".
222        let err = SeerError::SslError(
223            "could not resolve example.com for SSL inspection: DNS resolution failed".into(),
224        );
225        let msg = err.sanitized_message();
226        assert!(
227            msg.contains("SSL inspection failed"),
228            "expected category prefix; got: {msg}"
229        );
230        assert!(
231            msg.contains("DNS resolution failed"),
232            "expected detail to be preserved; got: {msg}"
233        );
234    }
235}