1use std::fmt;
16
17#[derive(Debug)]
35#[non_exhaustive]
36pub enum ScannerError {
37 NetworkError(String),
39 UrlParseError(String),
41 PayloadGenerationError(String),
43 PhaseTimeout,
45 TaskJoinFailed,
50 Cancelled,
52 BudgetExceeded(crate::RuntimeLimitExceeded),
54 InvalidDiscoveryLimits,
56 InvalidVerificationLimits,
58 DiscoveryStateLimitExceeded,
60 InvalidLegacyVerificationReport,
63 LegacyVerificationStateLimitExceeded,
65 InvalidTarget,
67 IoError(std::io::Error),
69 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
182pub 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}