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            SeerError::WhoisError(_) => "WHOIS lookup failed".to_string(),
94            SeerError::WhoisServerNotFound(detail) => {
95                format!("WHOIS server not found for this TLD: {}", detail)
96            }
97            SeerError::WhoisConnectionFailed(_) => "WHOIS connection failed".to_string(),
98            SeerError::RdapError(_) => "RDAP lookup failed".to_string(),
99            SeerError::RdapBootstrapError(_) => {
100                "RDAP service unavailable for this resource".to_string()
101            }
102            SeerError::DnsError(detail) => format!("DNS resolution failed: {}", detail),
103            SeerError::DnsResolverError(detail) => format!("DNS resolution failed: {}", detail),
104            SeerError::InvalidDomain(domain) => format!("Invalid domain name: {}", domain),
105            SeerError::DomainNotAllowed { tld, .. } => {
106                format!("Domain not allowed: TLD '{}' is not in the allowlist", tld)
107            }
108            SeerError::InvalidIpAddress(ip) => format!("Invalid IP address: {}", ip),
109            SeerError::InvalidRecordType(rt) => format!("Invalid record type: {}", rt),
110            SeerError::HttpError(_) => "HTTP request failed".to_string(),
111            SeerError::ReqwestError(_) => "HTTP request failed".to_string(),
112            SeerError::JsonError(_) => "Response parsing failed".to_string(),
113            SeerError::Timeout(_) => "Operation timed out".to_string(),
114            SeerError::RateLimited(_) => "Rate limited - please try again later".to_string(),
115            SeerError::CertificateError(_) => "Certificate validation failed".to_string(),
116            SeerError::SslError(detail) => format!("SSL inspection failed: {}", detail),
117            SeerError::BulkOperationError { .. } => "Bulk operation partially failed".to_string(),
118            SeerError::LookupFailed { domain, .. } => format!("Lookup failed for {}", domain),
119            SeerError::ConfigError(msg) => format!("Configuration error: {}", msg),
120            SeerError::InvalidInput(msg) => format!("Invalid input: {}", msg),
121            SeerError::Other(_) => "Operation failed".to_string(),
122            SeerError::RetryExhausted {
123                attempts,
124                last_error,
125            } => {
126                format!(
127                    "Operation failed after {} attempts: {}",
128                    attempts,
129                    last_error.sanitized_message()
130                )
131            }
132        }
133    }
134}
135
136pub type Result<T> = std::result::Result<T, SeerError>;
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn dns_error_sanitized_includes_detail() {
144        // Regression: prior to this, DnsError(_) was collapsed to the
145        // generic string "DNS resolution failed", swallowing the reason.
146        // Callers — especially Python wrappers — need the detail to
147        // distinguish "invalid nameserver", "record type not implemented",
148        // "NXDOMAIN", "hostname did not resolve", and friends. Same fix
149        // shape as the earlier SslError detail-preservation fix.
150        let err = SeerError::DnsError("invalid nameserver IP: foo.example".into());
151        let msg = err.sanitized_message();
152        assert!(
153            msg.contains("DNS resolution failed"),
154            "expected category prefix; got: {msg}"
155        );
156        assert!(
157            msg.contains("invalid nameserver IP"),
158            "expected detail to be preserved; got: {msg}"
159        );
160    }
161
162    #[test]
163    fn ssl_error_sanitized_includes_detail() {
164        // Regression: prior to this, SslError(_) was collapsed to the
165        // generic string "SSL inspection failed", swallowing the reason
166        // (DNS failure, handshake refused, no cert presented, etc.).
167        // Callers — especially Python wrappers — need the detail to
168        // distinguish "probe failed" from "certificate genuinely missing".
169        let err = SeerError::SslError(
170            "could not resolve example.com for SSL inspection: DNS resolution failed".into(),
171        );
172        let msg = err.sanitized_message();
173        assert!(
174            msg.contains("SSL inspection failed"),
175            "expected category prefix; got: {msg}"
176        );
177        assert!(
178            msg.contains("DNS resolution failed"),
179            "expected detail to be preserved; got: {msg}"
180        );
181    }
182}