Skip to main content

termivar_scanner/
api.rs

1//! REST API data models
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** opt-in via `platform-models`.
6//! - **Execution:** host/library only; no repository runtime caller.
7//! - **Default `termivar scan`:** no.
8//! - **Support:** experimental/scaffold.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! Request/response data contracts only. This module does not bind a listener
13//! or route HTTP requests.
14
15use crate::ScanFinding;
16use serde::{Deserialize, Serialize};
17
18/// API Request to start a new scan
19#[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/// Uninterpreted host configuration metadata.
27///
28/// The repository API model does not authorize or apply these values.
29#[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    /// Historical phase identifiers; this record does not execute them.
37    pub phases: Option<Vec<u8>>,
38}
39
40/// Scan status response
41#[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/// Scan status types
55#[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/// API Response wrapper
82#[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    /// Builds a success record at a caller-supplied timestamp.
92    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    /// Builds an error record at a caller-supplied timestamp.
102    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/// Scan result response
113#[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/// Finding filter for queries
126#[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/// Statistics response
136#[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/// API error types
258#[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}