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    /// (API responses, MCP tool results, Python exceptions).
91    ///
92    /// Transport/network variants collapse to their CATEGORY with no inner
93    /// detail, so an upstream server hostname/URL, a resolver-config fragment,
94    /// a filesystem path, or a raw system error can never reach an external
95    /// consumer. Full detail is still available for internal logging via the
96    /// `Display` impl (`to_string()`) — log that, return this. User-input
97    /// echoes (invalid domain / IP / record type / not-allowed TLD / generic
98    /// input validation) are kept because they are the caller's own input and
99    /// are needed to act on the error.
100    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            // Drop `details` (built from upstream RDAP/WHOIS error strings).
128            SeerError::LookupFailed { domain, .. } => format!("Lookup failed for {}", domain),
129            SeerError::ConfigError(_) => "Configuration error".to_string(),
130            // InvalidInput carries user-facing validation messages; the one
131            // sensitive case (an SSRF reserved-IP rejection) is already
132            // IP-redacted by `sanitize_error_for_public` on the lookup path.
133            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        // Strict policy: the external projection must NOT carry the inner
158        // detail of transport/network errors (upstream server hostnames, URLs,
159        // resolver internals, raw system errors). Full detail remains available
160        // for internal logging via Display (`to_string()`).
161        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        // The caller's own input is safe and necessary to act on the error.
209        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}