Skip to main content

termivar_scanner/
error.rs

1//! Error types and handling for Termivar Scanner
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** non-default `legacy-scanner` feature.
6//! - **Execution:** Surface A error contract used by historical scan phases.
7//! - **Default `termivar scan`:** no.
8//! - **Support:** legacy alpha.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! Comprehensive error handling with contextual information for all scanner phases.
13//! Implements proper error propagation with conversion from external error types.
14
15use std::fmt;
16
17/// Scanner error types with detailed context
18///
19/// # Variants
20/// - `NetworkError`: HTTP client, connection, or response parsing failures
21/// - `UrlParseError`: Invalid URL format or scheme
22/// - `PayloadGenerationError`: Malformed payload generation parameters
23/// - `PhaseTimeout`: Scanning phase exceeded timeout threshold
24/// - `TaskJoinFailed`: A structurally owned scanner worker failed to join
25/// - `Cancelled`: Host cancellation stopped bounded legacy transport
26/// - `BudgetExceeded`: A bounded legacy resource threshold stopped work
27/// - `InvalidDiscoveryLimits`: A discovery policy is incoherent or too large
28/// - `InvalidVerificationLimits`: A verification policy is incoherent or too large
29/// - `DiscoveryStateLimitExceeded`: A staged state transition exceeds retention bounds
30/// - `InvalidLegacyVerificationReport`: A legacy phase attempted to bypass claim policy
31/// - `LegacyVerificationStateLimitExceeded`: Verification outcome retention is exhausted
32/// - `InvalidTarget`: Target URL doesn't meet validation requirements
33/// - `IoError`: File system or I/O operation failures
34#[derive(Debug)]
35#[non_exhaustive]
36pub enum ScannerError {
37    /// Network I/O failures with detailed error message
38    NetworkError(String),
39    /// URL parsing failures with detailed error message
40    UrlParseError(String),
41    /// Payload generation failures with detailed error message
42    PayloadGenerationError(String),
43    /// Phase execution timeout
44    PhaseTimeout,
45    /// A structurally owned worker task failed to join.
46    ///
47    /// Deliberately carries no [`tokio::task::JoinError`] or panic payload so
48    /// target-controlled panic details cannot cross the scanner boundary.
49    TaskJoinFailed,
50    /// Host cancellation stopped bounded legacy transport.
51    Cancelled,
52    /// A shared bounded legacy resource limit denied or stopped work.
53    BudgetExceeded(crate::RuntimeLimitExceeded),
54    /// Discovery limits are zero, inconsistent, or exceed hard bounds.
55    InvalidDiscoveryLimits,
56    /// Verification limits are zero, inconsistent, or exceed hard bounds.
57    InvalidVerificationLimits,
58    /// A staged discovery update exceeded a deterministic state bound.
59    DiscoveryStateLimitExceeded,
60    /// A corrected legacy phase supplied a report outside the constrained
61    /// active, knowledge-only, manual-review bridge.
62    InvalidLegacyVerificationReport,
63    /// Accepted legacy verification outcomes exceeded the report bound.
64    LegacyVerificationStateLimitExceeded,
65    /// Target validation failure
66    InvalidTarget,
67    /// File I/O operation failure
68    IoError(std::io::Error),
69    /// Typed run report construction failed closed.
70    RunReport(termivar_core::RunReportError),
71}
72
73impl fmt::Display for ScannerError {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self {
76            ScannerError::NetworkError(e) => {
77                write!(
78                    f,
79                    "Network error: {e}. Check connectivity and target availability."
80                )
81            },
82            ScannerError::UrlParseError(e) => {
83                write!(
84                    f,
85                    "URL parse error: {e}. Ensure URL is valid and properly formatted."
86                )
87            },
88            ScannerError::PayloadGenerationError(e) => {
89                write!(
90                    f,
91                    "Payload generation error: {e}. Check payload parameters and syntax."
92                )
93            },
94            ScannerError::PhaseTimeout => {
95                write!(
96                    f,
97                    "Phase execution timeout. Increase timeout or reduce scan scope."
98                )
99            },
100            ScannerError::InvalidTarget => {
101                write!(f, "Invalid target URL. Provide valid HTTP/HTTPS URL.")
102            },
103            ScannerError::IoError(e) => {
104                write!(f, "IO error: {e}. Check file permissions and disk space.")
105            },
106            ScannerError::TaskJoinFailed => {
107                write!(
108                    f,
109                    "Scanner worker task failed to join; no result was accepted."
110                )
111            },
112            ScannerError::Cancelled => {
113                write!(f, "Host cancellation stopped bounded legacy transport.")
114            },
115            ScannerError::BudgetExceeded(limit) => {
116                write!(f, "Bounded legacy transport budget exhausted: {limit}")
117            },
118            ScannerError::InvalidDiscoveryLimits => {
119                write!(f, "Invalid bounded discovery limits.")
120            },
121            ScannerError::InvalidVerificationLimits => {
122                write!(f, "Invalid bounded legacy verification limits.")
123            },
124            ScannerError::DiscoveryStateLimitExceeded => {
125                write!(f, "Bounded discovery state limit exceeded.")
126            },
127            ScannerError::InvalidLegacyVerificationReport => {
128                write!(f, "Invalid legacy verification report was rejected.")
129            },
130            ScannerError::LegacyVerificationStateLimitExceeded => {
131                write!(f, "Bounded legacy verification outcome limit exceeded.")
132            },
133            ScannerError::RunReport(error) => write!(f, "Run report error: {error}"),
134        }
135    }
136}
137
138impl std::error::Error for ScannerError {
139    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
140        match self {
141            ScannerError::IoError(e) => Some(e),
142            ScannerError::RunReport(error) => Some(error),
143            ScannerError::BudgetExceeded(error) => Some(error),
144            _ => None,
145        }
146    }
147}
148
149impl From<reqwest::Error> for ScannerError {
150    fn from(err: reqwest::Error) -> Self {
151        let msg = if err.is_timeout() {
152            format!("HTTP timeout: {err}")
153        } else if err.is_connect() {
154            format!("Connection failed: {err}")
155        } else if err.status().is_some() {
156            format!("HTTP error: {err}")
157        } else {
158            err.to_string()
159        };
160        ScannerError::NetworkError(msg)
161    }
162}
163
164impl From<url::ParseError> for ScannerError {
165    fn from(err: url::ParseError) -> Self {
166        ScannerError::UrlParseError(err.to_string())
167    }
168}
169
170impl From<std::io::Error> for ScannerError {
171    fn from(err: std::io::Error) -> Self {
172        ScannerError::IoError(err)
173    }
174}
175
176impl From<termivar_core::RunReportError> for ScannerError {
177    fn from(error: termivar_core::RunReportError) -> Self {
178        Self::RunReport(error)
179    }
180}
181
182/// Result type for scanner operations
183pub type Result<T> = std::result::Result<T, ScannerError>;
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_error_display_messages() {
191        let errors = vec![
192            (ScannerError::PhaseTimeout, "timeout"),
193            (ScannerError::TaskJoinFailed, "failed to join"),
194            (ScannerError::InvalidTarget, "Invalid target"),
195            (
196                ScannerError::NetworkError("connection refused".to_string()),
197                "Network error",
198            ),
199            (
200                ScannerError::UrlParseError("invalid scheme".to_string()),
201                "URL parse error",
202            ),
203            (
204                ScannerError::PayloadGenerationError("invalid payload".to_owned()),
205                "Payload generation error",
206            ),
207            (
208                ScannerError::IoError(std::io::Error::new(
209                    std::io::ErrorKind::PermissionDenied,
210                    "denied",
211                )),
212                "IO error",
213            ),
214        ];
215
216        for (err, expected_text) in errors {
217            let display = format!("{err}");
218            assert!(display.contains(expected_text), "Error message: {display}");
219        }
220    }
221
222    #[test]
223    fn test_error_from_conversion() {
224        let url_err = url::Url::parse("invalid url").err().unwrap();
225        let scanner_err = ScannerError::from(url_err);
226        assert!(format!("{scanner_err:?}").contains("UrlParseError"));
227    }
228
229    #[test]
230    fn test_error_source_trait() {
231        use std::error::Error;
232        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
233        let scanner_err = ScannerError::from(io_err);
234        assert!(scanner_err.source().is_some());
235    }
236
237    #[tokio::test]
238    async fn reqwest_conversion_classifies_local_failures() {
239        use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
240        use tokio::net::TcpListener;
241
242        let status_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
243            .await
244            .unwrap();
245        let status_address = status_listener.local_addr().unwrap();
246        let status_server = tokio::spawn(async move {
247            let (mut stream, _) = status_listener.accept().await.unwrap();
248            let mut request = [0_u8; 1024];
249            let _ = stream.read(&mut request).await.unwrap();
250            stream
251                .write_all(
252                    b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
253                )
254                .await
255                .unwrap();
256        });
257        let client = reqwest::Client::builder()
258            .no_proxy()
259            .redirect(reqwest::redirect::Policy::none())
260            .timeout(std::time::Duration::from_secs(5))
261            .build()
262            .unwrap();
263        let status_error = client
264            .get(format!("http://{status_address}/"))
265            .send()
266            .await
267            .unwrap()
268            .error_for_status()
269            .unwrap_err();
270        status_server.await.unwrap();
271        assert!(status_error.status().is_some());
272        assert!(ScannerError::from(status_error)
273            .to_string()
274            .contains("HTTP error"));
275
276        let timeout_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
277            .await
278            .unwrap();
279        let timeout_address = timeout_listener.local_addr().unwrap();
280        let timeout_server = tokio::spawn(async move {
281            let (mut stream, _) = timeout_listener.accept().await.unwrap();
282            let mut request = [0_u8; 1024];
283            let _ = stream.read(&mut request).await.unwrap();
284            tokio::time::sleep(std::time::Duration::from_millis(250)).await;
285        });
286        let timeout_client = reqwest::Client::builder()
287            .no_proxy()
288            .redirect(reqwest::redirect::Policy::none())
289            .timeout(std::time::Duration::from_millis(25))
290            .build()
291            .unwrap();
292        let timeout_error = timeout_client
293            .get(format!("http://{timeout_address}/"))
294            .send()
295            .await
296            .unwrap_err();
297        timeout_server.abort();
298        let _ = timeout_server.await;
299        assert!(timeout_error.is_timeout());
300        assert!(ScannerError::from(timeout_error)
301            .to_string()
302            .contains("HTTP timeout"));
303
304        let closed_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
305            .await
306            .unwrap();
307        let closed_address = closed_listener.local_addr().unwrap();
308        drop(closed_listener);
309        let connect_error = client
310            .get(format!("http://{closed_address}/"))
311            .send()
312            .await
313            .unwrap_err();
314        assert!(connect_error.is_connect());
315        assert!(ScannerError::from(connect_error)
316            .to_string()
317            .contains("Connection failed"));
318    }
319
320    #[test]
321    fn bounded_legacy_errors_have_stable_text_and_sources() {
322        use std::error::Error;
323
324        let budget = crate::RuntimeLimitExceeded::new(
325            crate::RuntimeBudgetDimension::TotalRequests,
326            1,
327            2,
328            None,
329        );
330        let report = termivar_core::RunReportError::Blank {
331            field: "run target",
332        };
333        let cases = [
334            (
335                ScannerError::Cancelled,
336                "Host cancellation stopped bounded legacy transport.",
337            ),
338            (
339                ScannerError::BudgetExceeded(budget.clone()),
340                "Bounded legacy transport budget exhausted:",
341            ),
342            (
343                ScannerError::InvalidDiscoveryLimits,
344                "Invalid bounded discovery limits.",
345            ),
346            (
347                ScannerError::InvalidVerificationLimits,
348                "Invalid bounded legacy verification limits.",
349            ),
350            (
351                ScannerError::DiscoveryStateLimitExceeded,
352                "Bounded discovery state limit exceeded.",
353            ),
354            (
355                ScannerError::InvalidLegacyVerificationReport,
356                "Invalid legacy verification report was rejected.",
357            ),
358            (
359                ScannerError::LegacyVerificationStateLimitExceeded,
360                "Bounded legacy verification outcome limit exceeded.",
361            ),
362            (
363                ScannerError::RunReport(report.clone()),
364                "Run report error: run target must not be blank",
365            ),
366        ];
367        for (error, expected) in cases {
368            assert!(error.to_string().contains(expected));
369        }
370
371        assert!(ScannerError::BudgetExceeded(budget).source().is_some());
372        let converted = ScannerError::from(report);
373        assert!(converted.source().is_some());
374        assert!(matches!(converted, ScannerError::RunReport(_)));
375    }
376}