Skip to main content

termivar_scanner/
persistence.rs

1//! Persistence record and schema metadata.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** opt-in via `platform-models`.
6//! - **Execution:** no repository runtime caller (not on the default scan path).
7//! - **Default `termivar scan`:** no.
8//! - **Support:** experimental data models.
9//!
10//! This module contains serializable records and an in-memory schema catalog.
11//! It does not build SQL, connect to a database, manage a pool, execute a
12//! transaction, or provide durable storage.
13
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16
17/// Kind of record represented by persistence metadata.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19pub enum EntityType {
20    #[serde(rename = "scan")]
21    Scan,
22    #[serde(rename = "finding")]
23    Finding,
24    #[serde(rename = "endpoint")]
25    Endpoint,
26    #[serde(rename = "vulnerability")]
27    Vulnerability,
28    #[serde(rename = "scan_result")]
29    ScanResult,
30}
31
32impl EntityType {
33    pub fn as_str(self) -> &'static str {
34        match self {
35            Self::Scan => "scan",
36            Self::Finding => "finding",
37            Self::Endpoint => "endpoint",
38            Self::Vulnerability => "vulnerability",
39            Self::ScanResult => "scan_result",
40        }
41    }
42}
43
44/// Caller-supplied scan record.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct ScanRecord {
47    pub scan_id: String,
48    pub target_url: String,
49    pub status: String,
50    pub started_at: u64,
51    pub completed_at: Option<u64>,
52    pub duration_ms: Option<u64>,
53    pub findings_count: u32,
54    pub critical_count: u32,
55    pub high_count: u32,
56}
57
58/// Caller-supplied finding record.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct FindingRecord {
61    pub finding_id: String,
62    pub scan_id: String,
63    pub phase: u8,
64    pub module_name: String,
65    pub severity: String,
66    pub description: String,
67    pub evidence: String,
68    pub discovered_at: u64,
69}
70
71/// Caller-supplied endpoint record.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct EndpointRecord {
74    pub endpoint_id: String,
75    pub scan_id: String,
76    pub url: String,
77    pub method: String,
78    pub status_code: u32,
79    pub response_time_ms: u32,
80    pub discovered_at: u64,
81}
82
83/// Declarative table metadata. Values are not interpreted as SQL.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct TableSchema {
86    pub table_name: String,
87    pub columns: Vec<ColumnDef>,
88    pub indexes: Vec<IndexDef>,
89    pub primary_key: String,
90}
91
92/// Declarative column metadata. `data_type` is an opaque caller-supplied label.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct ColumnDef {
95    pub name: String,
96    pub data_type: String,
97    pub nullable: bool,
98    pub indexed: bool,
99}
100
101/// Declarative index metadata.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct IndexDef {
104    pub name: String,
105    pub columns: Vec<String>,
106    pub unique: bool,
107}
108
109/// In-memory catalog of caller-supplied schema metadata.
110#[derive(Debug, Clone, Default)]
111pub struct SchemaManager {
112    schemas: HashMap<String, TableSchema>,
113}
114
115impl SchemaManager {
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Records metadata and returns the previous value for the same name.
121    ///
122    /// This method does not create or alter a database table.
123    pub fn register_schema(&mut self, schema: TableSchema) -> Option<TableSchema> {
124        self.schemas.insert(schema.table_name.clone(), schema)
125    }
126
127    pub fn get_schema(&self, table_name: &str) -> Option<&TableSchema> {
128        self.schemas.get(table_name)
129    }
130
131    pub fn schema_count(&self) -> usize {
132        self.schemas.len()
133    }
134
135    pub fn is_empty(&self) -> bool {
136        self.schemas.is_empty()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn schema(name: &str, data_type: &str) -> TableSchema {
145        TableSchema {
146            table_name: name.to_string(),
147            columns: vec![ColumnDef {
148                name: "scan_id".to_string(),
149                data_type: data_type.to_string(),
150                nullable: false,
151                indexed: true,
152            }],
153            indexes: Vec::new(),
154            primary_key: "scan_id".to_string(),
155        }
156    }
157
158    #[test]
159    fn entity_type_is_metadata_without_table_mapping() {
160        assert_eq!(EntityType::Finding.as_str(), "finding");
161    }
162
163    #[test]
164    fn schema_catalog_is_empty_until_the_caller_records_metadata() {
165        let catalog = SchemaManager::new();
166        assert!(catalog.is_empty());
167        assert!(catalog.get_schema("scans").is_none());
168    }
169
170    #[test]
171    fn duplicate_schema_names_replace_and_return_the_previous_record() {
172        let mut catalog = SchemaManager::new();
173        assert!(catalog.register_schema(schema("scans", "TEXT")).is_none());
174
175        let previous = catalog
176            .register_schema(schema("scans", "opaque-not-sql"))
177            .expect("the original metadata must be returned");
178
179        assert_eq!(previous.columns[0].data_type, "TEXT");
180        assert_eq!(catalog.schema_count(), 1);
181        assert_eq!(
182            catalog.get_schema("scans").unwrap().columns[0].data_type,
183            "opaque-not-sql"
184        );
185    }
186
187    #[test]
188    fn schema_values_remain_opaque_metadata() {
189        let suspicious = "TEXT); DROP TABLE scans; --";
190        let mut catalog = SchemaManager::new();
191        let _ = catalog.register_schema(schema("scans", suspicious));
192
193        assert_eq!(
194            catalog.get_schema("scans").unwrap().columns[0].data_type,
195            suspicious
196        );
197    }
198}