1use crate::ScanFinding;
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct StartScanRequest {
21 pub target: String,
22 pub config: Option<ScanConfigRequest>,
23 pub tags: Option<Vec<String>>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ScanConfigRequest {
31 pub intensity: Option<String>,
32 pub timeout_secs: Option<u64>,
33 pub max_concurrency: Option<u32>,
34 #[serde(with = "optional_positive_f32")]
35 pub rate_limit: Option<f32>,
36 pub phases: Option<Vec<u8>>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ScanStatus {
43 pub scan_id: String,
44 pub target: String,
45 pub status: ScanStatusType,
46 #[serde(with = "percent_f32")]
47 pub progress: f32,
48 pub findings_count: u64,
49 pub elapsed_ms: u64,
50 pub started_at: u64,
51 pub current_phase: Option<u8>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56pub enum ScanStatusType {
57 #[serde(rename = "queued")]
58 Queued,
59 #[serde(rename = "running")]
60 Running,
61 #[serde(rename = "completed")]
62 Completed,
63 #[serde(rename = "failed")]
64 Failed,
65 #[serde(rename = "paused")]
66 Paused,
67}
68
69impl ScanStatusType {
70 pub fn as_str(&self) -> &str {
71 match self {
72 ScanStatusType::Queued => "queued",
73 ScanStatusType::Running => "running",
74 ScanStatusType::Completed => "completed",
75 ScanStatusType::Failed => "failed",
76 ScanStatusType::Paused => "paused",
77 }
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ApiResponse<T> {
84 pub success: bool,
85 pub data: Option<T>,
86 pub error: Option<String>,
87 pub timestamp: u64,
88}
89
90impl<T> ApiResponse<T> {
91 pub fn ok_at(data: T, timestamp: u64) -> Self {
93 Self {
94 success: true,
95 data: Some(data),
96 error: None,
97 timestamp,
98 }
99 }
100
101 pub fn err_at(error: String, timestamp: u64) -> Self {
103 Self {
104 success: false,
105 data: None,
106 error: Some(error),
107 timestamp,
108 }
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct ScanResultResponse {
115 pub scan_id: String,
116 pub target: String,
117 pub status: String,
118 pub findings: Vec<ScanFinding>,
119 #[serde(with = "unit_interval_f32")]
120 pub risk_score: f32,
121 pub duration_ms: u64,
122 pub completed_at: u64,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct FindingFilter {
128 pub severity: Option<String>,
129 pub phase: Option<u8>,
130 pub module: Option<String>,
131 pub offset: Option<u64>,
132 pub limit: Option<u64>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct StatsResponse {
138 pub total_scans: u64,
139 pub completed_scans: u64,
140 pub total_findings: u64,
141 pub critical_count: u64,
142 pub high_count: u64,
143 pub medium_count: u64,
144 pub low_count: u64,
145 #[serde(with = "nonnegative_f64")]
146 pub avg_scan_duration_ms: f64,
147}
148
149mod optional_positive_f32 {
150 use serde::{Deserialize, Deserializer, Serializer};
151
152 pub fn serialize<S>(value: &Option<f32>, serializer: S) -> Result<S::Ok, S::Error>
153 where
154 S: Serializer,
155 {
156 match value {
157 Some(value) if value.is_finite() && *value > 0.0 => serializer.serialize_some(value),
158 Some(_) => Err(serde::ser::Error::custom(
159 "rate limit must be finite and greater than zero",
160 )),
161 None => serializer.serialize_none(),
162 }
163 }
164
165 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<f32>, D::Error>
166 where
167 D: Deserializer<'de>,
168 {
169 let value = Option::<f32>::deserialize(deserializer)?;
170 match value {
171 Some(value) if value.is_finite() && value > 0.0 => Ok(Some(value)),
172 Some(_) => Err(serde::de::Error::custom(
173 "rate limit must be finite and greater than zero",
174 )),
175 None => Ok(None),
176 }
177 }
178}
179
180macro_rules! ranged_float_serde {
181 ($module:ident, $type:ty, $minimum:expr, $maximum:expr, $message:literal) => {
182 mod $module {
183 use serde::{Deserialize, Deserializer, Serializer};
184
185 pub fn serialize<S>(value: &$type, serializer: S) -> Result<S::Ok, S::Error>
186 where
187 S: Serializer,
188 {
189 if value.is_finite() && ($minimum..=$maximum).contains(value) {
190 serializer.serialize_f64(*value as f64)
191 } else {
192 Err(serde::ser::Error::custom($message))
193 }
194 }
195
196 pub fn deserialize<'de, D>(deserializer: D) -> Result<$type, D::Error>
197 where
198 D: Deserializer<'de>,
199 {
200 let value = <$type>::deserialize(deserializer)?;
201 if value.is_finite() && ($minimum..=$maximum).contains(&value) {
202 Ok(value)
203 } else {
204 Err(serde::de::Error::custom($message))
205 }
206 }
207 }
208 };
209}
210
211ranged_float_serde!(
212 percent_f32,
213 f32,
214 0.0_f32,
215 100.0_f32,
216 "progress must be finite and between 0 and 100"
217);
218ranged_float_serde!(
219 unit_interval_f32,
220 f32,
221 0.0_f32,
222 1.0_f32,
223 "risk score must be finite and between 0 and 1"
224);
225
226mod nonnegative_f64 {
227 use serde::{Deserialize, Deserializer, Serializer};
228
229 pub fn serialize<S>(value: &f64, serializer: S) -> Result<S::Ok, S::Error>
230 where
231 S: Serializer,
232 {
233 if value.is_finite() && *value >= 0.0 {
234 serializer.serialize_f64(*value)
235 } else {
236 Err(serde::ser::Error::custom(
237 "average duration must be finite and non-negative",
238 ))
239 }
240 }
241
242 pub fn deserialize<'de, D>(deserializer: D) -> Result<f64, D::Error>
243 where
244 D: Deserializer<'de>,
245 {
246 let value = f64::deserialize(deserializer)?;
247 if value.is_finite() && value >= 0.0 {
248 Ok(value)
249 } else {
250 Err(serde::de::Error::custom(
251 "average duration must be finite and non-negative",
252 ))
253 }
254 }
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub enum ApiError {
260 ScanNotFound(String),
261 InvalidConfig(String),
262 InvalidTarget(String),
263 ScanAlreadyRunning(String),
264 InternalError(String),
265}
266
267impl ApiError {
268 pub fn message(&self) -> String {
269 match self {
270 ApiError::ScanNotFound(id) => format!("Scan not found: {id}"),
271 ApiError::InvalidConfig(msg) => format!("Invalid configuration: {msg}"),
272 ApiError::InvalidTarget(url) => format!("Invalid target URL: {url}"),
273 ApiError::ScanAlreadyRunning(id) => format!("Scan already running: {id}"),
274 ApiError::InternalError(msg) => format!("Internal error: {msg}"),
275 }
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn test_start_scan_request() {
285 let req = StartScanRequest {
286 target: "https://example.com".to_string(),
287 config: None,
288 tags: None,
289 };
290
291 assert_eq!(req.target, "https://example.com");
292 }
293
294 #[test]
295 fn test_scan_status() {
296 let status = ScanStatus {
297 scan_id: "scan123".to_string(),
298 target: "https://example.com".to_string(),
299 status: ScanStatusType::Running,
300 progress: 50.0,
301 findings_count: 10,
302 elapsed_ms: 5000,
303 started_at: 1000,
304 current_phase: Some(5),
305 };
306
307 assert_eq!(status.progress, 50.0);
308 assert_eq!(status.findings_count, 10);
309 }
310
311 #[test]
312 fn test_scan_status_type_str() {
313 assert_eq!(ScanStatusType::Running.as_str(), "running");
314 assert_eq!(ScanStatusType::Completed.as_str(), "completed");
315 }
316
317 #[test]
318 fn test_api_response_ok() {
319 let response: ApiResponse<String> = ApiResponse::ok_at("Success".to_string(), 123);
320 assert!(response.success);
321 assert_eq!(response.data, Some("Success".to_string()));
322 assert!(response.error.is_none());
323 }
324
325 #[test]
326 fn test_api_response_err() {
327 let response: ApiResponse<String> = ApiResponse::err_at("Error".to_string(), 123);
328 assert!(!response.success);
329 assert!(response.data.is_none());
330 assert_eq!(response.error, Some("Error".to_string()));
331 }
332
333 #[test]
334 fn test_finding_filter() {
335 let filter = FindingFilter {
336 severity: Some("CRITICAL".to_string()),
337 phase: Some(5),
338 module: None,
339 offset: Some(0),
340 limit: Some(50),
341 };
342
343 assert_eq!(filter.severity, Some("CRITICAL".to_string()));
344 assert_eq!(filter.limit, Some(50));
345 }
346
347 #[test]
348 fn test_stats_response() {
349 let stats = StatsResponse {
350 total_scans: 100,
351 completed_scans: 95,
352 total_findings: 500,
353 critical_count: 10,
354 high_count: 50,
355 medium_count: 200,
356 low_count: 240,
357 avg_scan_duration_ms: 5000.0,
358 };
359
360 assert_eq!(stats.total_scans, 100);
361 assert_eq!(stats.critical_count, 10);
362 }
363
364 #[test]
365 fn test_api_error_messages() {
366 let cases = [
367 (
368 ApiError::ScanNotFound("abc123".to_owned()),
369 "Scan not found: abc123",
370 ),
371 (
372 ApiError::InvalidConfig("bounded".to_owned()),
373 "Invalid configuration: bounded",
374 ),
375 (
376 ApiError::InvalidTarget("invalid".to_owned()),
377 "Invalid target URL: invalid",
378 ),
379 (
380 ApiError::ScanAlreadyRunning("abc123".to_owned()),
381 "Scan already running: abc123",
382 ),
383 (
384 ApiError::InternalError("redacted".to_owned()),
385 "Internal error: redacted",
386 ),
387 ];
388
389 for (error, expected) in cases {
390 assert_eq!(error.message(), expected);
391 }
392 }
393
394 #[test]
395 fn test_scan_result_response() {
396 let result = ScanResultResponse {
397 scan_id: "scan1".to_string(),
398 target: "https://example.com".to_string(),
399 status: "completed".to_string(),
400 findings: vec![],
401 risk_score: 0.65,
402 duration_ms: 10000,
403 completed_at: 2000,
404 };
405
406 assert_eq!(result.risk_score, 0.65);
407 assert_eq!(result.findings.len(), 0);
408 }
409
410 #[test]
411 fn test_scan_config_request() {
412 let config = ScanConfigRequest {
413 intensity: Some("aggressive".to_string()),
414 timeout_secs: Some(10),
415 max_concurrency: Some(100),
416 rate_limit: Some(50.0),
417 phases: Some(vec![1, 2, 3, 5, 6, 7, 8]),
418 };
419
420 assert_eq!(config.intensity, Some("aggressive".to_string()));
421 assert_eq!(config.max_concurrency, Some(100));
422 assert_eq!(config.phases, Some(vec![1, 2, 3, 5, 6, 7, 8]));
423 }
424
425 #[test]
426 fn api_wire_rejects_nonfinite_and_out_of_range_numbers() {
427 let invalid_status = ScanStatus {
428 scan_id: "scan".into(),
429 target: "https://example.test".into(),
430 status: ScanStatusType::Running,
431 progress: f32::NAN,
432 findings_count: 0,
433 elapsed_ms: 0,
434 started_at: 0,
435 current_phase: None,
436 };
437 assert!(serde_json::to_string(&invalid_status).is_err());
438
439 let invalid_config = r#"{
440 "intensity": null,
441 "timeout_secs": null,
442 "max_concurrency": null,
443 "rate_limit": -1.0,
444 "phases": null
445 }"#;
446 assert!(serde_json::from_str::<ScanConfigRequest>(invalid_config).is_err());
447
448 let invalid_stats = StatsResponse {
449 total_scans: 0,
450 completed_scans: 0,
451 total_findings: 0,
452 critical_count: 0,
453 high_count: 0,
454 medium_count: 0,
455 low_count: 0,
456 avg_scan_duration_ms: f64::INFINITY,
457 };
458 assert!(serde_json::to_string(&invalid_stats).is_err());
459
460 let invalid_optional_rate = ScanConfigRequest {
461 intensity: None,
462 timeout_secs: None,
463 max_concurrency: None,
464 rate_limit: Some(f32::NAN),
465 phases: None,
466 };
467 assert!(serde_json::to_string(&invalid_optional_rate).is_err());
468
469 let invalid_duration = r#"{
470 "total_scans":0,"completed_scans":0,"total_findings":0,
471 "critical_count":0,"high_count":0,"medium_count":0,"low_count":0,
472 "avg_scan_duration_ms":-1.0
473 }"#;
474 assert!(serde_json::from_str::<StatsResponse>(invalid_duration).is_err());
475 }
476
477 #[test]
478 fn api_wire_valid_numeric_boundaries_round_trip() {
479 let optional_rate = ScanConfigRequest {
480 intensity: None,
481 timeout_secs: None,
482 max_concurrency: None,
483 rate_limit: None,
484 phases: None,
485 };
486 let encoded = serde_json::to_string(&optional_rate).unwrap();
487 let decoded: ScanConfigRequest = serde_json::from_str(&encoded).unwrap();
488 assert_eq!(decoded.rate_limit, None);
489
490 let status = ScanStatus {
491 scan_id: "scan".to_string(),
492 target: "https://example.test".to_string(),
493 status: ScanStatusType::Running,
494 progress: 100.0,
495 findings_count: 0,
496 elapsed_ms: 1,
497 started_at: 1,
498 current_phase: None,
499 };
500 let encoded = serde_json::to_string(&status).unwrap();
501 let decoded: ScanStatus = serde_json::from_str(&encoded).unwrap();
502 assert_eq!(decoded.progress, 100.0);
503
504 let result = ScanResultResponse {
505 scan_id: "scan".to_string(),
506 target: "https://example.test".to_string(),
507 status: "complete".to_string(),
508 findings: Vec::new(),
509 risk_score: 1.0,
510 duration_ms: 1,
511 completed_at: 1,
512 };
513 let encoded = serde_json::to_string(&result).unwrap();
514 let decoded: ScanResultResponse = serde_json::from_str(&encoded).unwrap();
515 assert_eq!(decoded.risk_score, 1.0);
516
517 let stats = StatsResponse {
518 total_scans: 1,
519 completed_scans: 1,
520 total_findings: 0,
521 critical_count: 0,
522 high_count: 0,
523 medium_count: 0,
524 low_count: 0,
525 avg_scan_duration_ms: 0.0,
526 };
527 let encoded = serde_json::to_string(&stats).unwrap();
528 let decoded: StatsResponse = serde_json::from_str(&encoded).unwrap();
529 assert_eq!(decoded.avg_scan_duration_ms, 0.0);
530 }
531
532 #[test]
533 fn api_wire_counts_are_fixed_width() {
534 let filter = FindingFilter {
535 severity: None,
536 phase: None,
537 module: None,
538 offset: Some(u64::MAX),
539 limit: Some(u64::MAX),
540 };
541 let encoded = serde_json::to_string(&filter).expect("fixed-width record serializes");
542 let decoded: FindingFilter = serde_json::from_str(&encoded).expect("record round-trips");
543 assert_eq!(decoded.offset, Some(u64::MAX));
544 assert_eq!(decoded.limit, Some(u64::MAX));
545 }
546}