Skip to main content

termivar_scanner/
config_loader.rs

1//! In-memory profile models and registry.
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//! This module does not parse TOML, load files, or drive a scanner. Hosts may
13//! use it to store descriptive profile values for an experimental integration.
14
15use crate::config::ScanIntensity;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19/// Scan profile configuration
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ScanProfile {
22    pub name: String,
23    pub description: String,
24    pub scan_intensity: ScanIntensity,
25    pub timeout_secs: u32,
26    pub rate_limit_rps: u32,
27    pub concurrent_workers: u32,
28    pub plugins_enabled: Vec<String>,
29    pub lua_scripts_enabled: Vec<String>,
30    pub event_subscriptions: Vec<String>,
31    pub options: HashMap<String, String>,
32}
33
34/// Enterprise profile - Compliance-focused
35impl Default for ScanProfile {
36    fn default() -> Self {
37        Self::enterprise()
38    }
39}
40
41impl ScanProfile {
42    /// Creates enterprise profile
43    pub fn enterprise() -> Self {
44        Self {
45            name: "enterprise".to_string(),
46            description: "Descriptive enterprise policy model; not runtime-wired".to_string(),
47            scan_intensity: ScanIntensity::Normal,
48            timeout_secs: 300,
49            rate_limit_rps: 10,
50            concurrent_workers: 4,
51            plugins_enabled: vec![],
52            lua_scripts_enabled: vec![],
53            event_subscriptions: vec![],
54            options: HashMap::new(),
55        }
56    }
57
58    /// Creates cloud scanning profile
59    pub fn cloud() -> Self {
60        Self {
61            name: "cloud".to_string(),
62            description: "Descriptive cloud policy model; not runtime-wired".to_string(),
63            scan_intensity: ScanIntensity::Aggressive,
64            timeout_secs: 600,
65            rate_limit_rps: 50,
66            concurrent_workers: 16,
67            plugins_enabled: vec![],
68            lua_scripts_enabled: vec![],
69            event_subscriptions: vec![],
70            options: HashMap::new(),
71        }
72    }
73
74    /// Creates aggressive scanning profile
75    pub fn aggressive() -> Self {
76        Self {
77            name: "aggressive".to_string(),
78            description: "Descriptive high-intensity policy model; not runtime-wired".to_string(),
79            scan_intensity: ScanIntensity::Aggressive,
80            timeout_secs: 180,
81            rate_limit_rps: 100,
82            concurrent_workers: 32,
83            plugins_enabled: vec![],
84            lua_scripts_enabled: vec![],
85            event_subscriptions: vec![],
86            options: HashMap::new(),
87        }
88    }
89
90    /// Creates passive scanning profile
91    pub fn passive() -> Self {
92        Self {
93            name: "passive".to_string(),
94            description: "Descriptive low-activity policy model; not runtime-wired".to_string(),
95            scan_intensity: ScanIntensity::Stealth,
96            timeout_secs: 60,
97            rate_limit_rps: 5,
98            concurrent_workers: 2,
99            plugins_enabled: vec![],
100            lua_scripts_enabled: vec![],
101            event_subscriptions: vec![],
102            options: HashMap::new(),
103        }
104    }
105
106    /// Creates custom profile
107    pub fn custom(name: impl Into<String>, intensity: ScanIntensity) -> Self {
108        Self {
109            name: name.into(),
110            description: String::new(),
111            scan_intensity: intensity,
112            timeout_secs: 300,
113            rate_limit_rps: 20,
114            concurrent_workers: 8,
115            plugins_enabled: vec![],
116            lua_scripts_enabled: vec![],
117            event_subscriptions: vec![],
118            options: HashMap::new(),
119        }
120    }
121
122    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
123        self.description = desc.into();
124        self
125    }
126
127    pub fn with_timeout(mut self, secs: u32) -> Self {
128        self.timeout_secs = secs;
129        self
130    }
131
132    pub fn with_rate_limit(mut self, rps: u32) -> Self {
133        self.rate_limit_rps = rps;
134        self
135    }
136
137    pub fn with_workers(mut self, count: u32) -> Self {
138        self.concurrent_workers = count;
139        self
140    }
141
142    pub fn add_plugin(mut self, plugin_id: impl Into<String>) -> Self {
143        self.plugins_enabled.push(plugin_id.into());
144        self
145    }
146
147    pub fn add_script(mut self, script_id: impl Into<String>) -> Self {
148        self.lua_scripts_enabled.push(script_id.into());
149        self
150    }
151
152    pub fn add_subscription(mut self, event: impl Into<String>) -> Self {
153        self.event_subscriptions.push(event.into());
154        self
155    }
156
157    pub fn add_option(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
158        self.options.insert(key.into(), value.into());
159        self
160    }
161}
162
163/// In-memory profile registry; it performs no file or environment loading.
164pub struct ConfigLoader {
165    profiles: dashmap::DashMap<String, ScanProfile>,
166    active_profile: std::sync::Mutex<String>,
167}
168
169impl ConfigLoader {
170    /// Creates new config loader
171    pub fn new() -> Self {
172        let profiles = dashmap::DashMap::new();
173
174        // Load built-in profiles
175        profiles.insert("enterprise".to_string(), ScanProfile::enterprise());
176        profiles.insert("cloud".to_string(), ScanProfile::cloud());
177        profiles.insert("aggressive".to_string(), ScanProfile::aggressive());
178        profiles.insert("passive".to_string(), ScanProfile::passive());
179
180        Self {
181            profiles,
182            active_profile: std::sync::Mutex::new("enterprise".to_string()),
183        }
184    }
185
186    /// Registers a profile
187    pub fn register_profile(&self, profile: ScanProfile) {
188        self.profiles.insert(profile.name.clone(), profile);
189    }
190
191    /// Gets profile by name
192    pub fn get_profile(&self, name: &str) -> Option<ScanProfile> {
193        self.profiles.get(name).map(|p| p.clone())
194    }
195
196    /// Lists all available profiles
197    pub fn list_profiles(&self) -> Vec<String> {
198        let mut profiles = self
199            .profiles
200            .iter()
201            .map(|ref_multi| ref_multi.key().clone())
202            .collect::<Vec<_>>();
203        profiles.sort();
204        profiles
205    }
206
207    /// Gets currently active profile
208    pub fn get_active_profile(&self) -> ScanProfile {
209        let name = self.active_profile.lock().unwrap().clone();
210        self.get_profile(&name)
211            .unwrap_or_else(ScanProfile::enterprise)
212    }
213
214    /// Sets active profile
215    pub fn set_active_profile(&self, name: &str) -> Result<(), String> {
216        if self.profiles.contains_key(name) {
217            *self.active_profile.lock().unwrap() = name.to_string();
218            Ok(())
219        } else {
220            Err(format!("Profile '{name}' not found"))
221        }
222    }
223
224    /// Gets profile count
225    pub fn profile_count(&self) -> usize {
226        self.profiles.len()
227    }
228
229    /// Overlays every profile field using deterministic collection semantics.
230    ///
231    /// Scalar fields come from `overlay`. List fields retain the base order,
232    /// append overlay-only values in overlay order, and remove duplicates.
233    /// Overlay option values replace base values with the same key.
234    pub fn merge_profiles(&self, base: &str, overlay: &str) -> Result<ScanProfile, String> {
235        let base_profile = self
236            .get_profile(base)
237            .ok_or_else(|| format!("Base profile '{base}' not found"))?;
238        let overlay_profile = self
239            .get_profile(overlay)
240            .ok_or_else(|| format!("Overlay profile '{overlay}' not found"))?;
241
242        let mut merged = base_profile;
243        merged.name = format!("{base}_merged_with_{overlay}");
244        merged.description = overlay_profile.description;
245        merged.scan_intensity = overlay_profile.scan_intensity;
246        merged.timeout_secs = overlay_profile.timeout_secs;
247        merged.rate_limit_rps = overlay_profile.rate_limit_rps;
248        merged.concurrent_workers = overlay_profile.concurrent_workers;
249        extend_unique(&mut merged.plugins_enabled, overlay_profile.plugins_enabled);
250        extend_unique(
251            &mut merged.lua_scripts_enabled,
252            overlay_profile.lua_scripts_enabled,
253        );
254        extend_unique(
255            &mut merged.event_subscriptions,
256            overlay_profile.event_subscriptions,
257        );
258        merged.options.extend(overlay_profile.options);
259
260        Ok(merged)
261    }
262}
263
264fn extend_unique(values: &mut Vec<String>, additions: Vec<String>) {
265    for addition in additions {
266        if !values.contains(&addition) {
267            values.push(addition);
268        }
269    }
270}
271
272impl Default for ConfigLoader {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_scan_intensity() {
284        assert_eq!(ScanIntensity::Light.as_str(), "light");
285        assert_eq!(ScanIntensity::Stealth.as_str(), "stealth");
286    }
287
288    #[test]
289    fn test_enterprise_profile() {
290        let profile = ScanProfile::enterprise();
291        assert_eq!(profile.name, "enterprise");
292        assert_eq!(profile.scan_intensity, ScanIntensity::Normal);
293        assert_eq!(profile.concurrent_workers, 4);
294    }
295
296    #[test]
297    fn test_cloud_profile() {
298        let profile = ScanProfile::cloud();
299        assert_eq!(profile.name, "cloud");
300        assert_eq!(profile.scan_intensity, ScanIntensity::Aggressive);
301        assert_eq!(profile.concurrent_workers, 16);
302    }
303
304    #[test]
305    fn test_aggressive_profile() {
306        let profile = ScanProfile::aggressive();
307        assert_eq!(profile.name, "aggressive");
308        assert_eq!(profile.scan_intensity, ScanIntensity::Aggressive);
309        assert!(profile.plugins_enabled.is_empty());
310    }
311
312    #[test]
313    fn built_in_profiles_do_not_claim_unwired_capabilities() {
314        for profile in [
315            ScanProfile::enterprise(),
316            ScanProfile::cloud(),
317            ScanProfile::aggressive(),
318            ScanProfile::passive(),
319        ] {
320            assert!(profile.plugins_enabled.is_empty());
321            assert!(profile.lua_scripts_enabled.is_empty());
322            assert!(profile.event_subscriptions.is_empty());
323            assert!(profile.options.is_empty());
324            assert!(profile.description.contains("not runtime-wired"));
325        }
326    }
327
328    #[test]
329    fn test_passive_profile() {
330        let profile = ScanProfile::passive();
331        assert_eq!(profile.name, "passive");
332        assert_eq!(profile.scan_intensity, ScanIntensity::Stealth);
333        assert_eq!(profile.plugins_enabled.len(), 0);
334    }
335
336    #[test]
337    fn test_custom_profile() {
338        let profile = ScanProfile::custom("test", ScanIntensity::Normal)
339            .with_description("Test Profile")
340            .with_timeout(120)
341            .with_rate_limit(50)
342            .with_workers(8);
343
344        assert_eq!(profile.name, "test");
345        assert_eq!(profile.description, "Test Profile");
346        assert_eq!(profile.timeout_secs, 120);
347        assert_eq!(profile.rate_limit_rps, 50);
348        assert_eq!(profile.concurrent_workers, 8);
349    }
350
351    #[test]
352    fn test_profile_with_plugins() {
353        let profile = ScanProfile::custom("test", ScanIntensity::Normal)
354            .add_plugin("example.marker.one")
355            .add_plugin("example.marker.two");
356
357        assert_eq!(profile.plugins_enabled.len(), 2);
358    }
359
360    #[test]
361    fn test_profile_with_scripts() {
362        let profile = ScanProfile::custom("test", ScanIntensity::Normal)
363            .add_script("script1")
364            .add_script("script2");
365
366        assert_eq!(profile.lua_scripts_enabled.len(), 2);
367    }
368
369    #[test]
370    fn test_profile_with_options() {
371        let profile = ScanProfile::custom("test", ScanIntensity::Normal)
372            .add_option("key1", "value1")
373            .add_option("key2", "value2");
374
375        assert_eq!(profile.options.get("key1"), Some(&"value1".to_string()));
376    }
377
378    #[test]
379    fn test_config_loader_creation() {
380        let loader = ConfigLoader::new();
381        assert!(loader.profile_count() >= 4);
382    }
383
384    #[test]
385    fn test_config_loader_get_profile() {
386        let loader = ConfigLoader::new();
387        let enterprise = loader.get_profile("enterprise");
388
389        assert!(enterprise.is_some());
390        assert_eq!(enterprise.unwrap().name, "enterprise");
391    }
392
393    #[test]
394    fn test_config_loader_list_profiles() {
395        let loader = ConfigLoader::new();
396        let profiles = loader.list_profiles();
397
398        assert!(profiles.contains(&"enterprise".to_string()));
399        assert!(profiles.contains(&"cloud".to_string()));
400        assert!(profiles.contains(&"aggressive".to_string()));
401        assert!(profiles.contains(&"passive".to_string()));
402    }
403
404    #[test]
405    fn test_config_loader_active_profile() {
406        let loader = ConfigLoader::new();
407        let active = loader.get_active_profile();
408
409        assert_eq!(active.name, "enterprise");
410    }
411
412    #[test]
413    fn test_config_loader_set_active() {
414        let loader = ConfigLoader::new();
415        loader.set_active_profile("cloud").unwrap();
416
417        let active = loader.get_active_profile();
418        assert_eq!(active.name, "cloud");
419
420        let missing = loader.set_active_profile("missing").unwrap_err();
421        assert_eq!(missing, "Profile 'missing' not found");
422        assert_eq!(loader.get_active_profile().name, "cloud");
423    }
424
425    #[test]
426    fn test_config_loader_register_profile() {
427        let loader = ConfigLoader::new();
428        let initial_count = loader.profile_count();
429
430        let custom = ScanProfile::custom("custom", ScanIntensity::Normal);
431        loader.register_profile(custom);
432
433        assert_eq!(loader.profile_count(), initial_count + 1);
434        assert!(loader.get_profile("custom").is_some());
435    }
436
437    #[test]
438    fn profile_merge_overlays_every_field_deterministically() {
439        let loader = ConfigLoader::new();
440
441        let mut base = ScanProfile::custom("base", ScanIntensity::Light)
442            .with_description("base description")
443            .with_timeout(10)
444            .with_rate_limit(2)
445            .with_workers(1)
446            .add_plugin("base-plugin")
447            .add_plugin("shared-plugin")
448            .add_script("base-script")
449            .add_subscription("base-event")
450            .add_option("base-only", "base")
451            .add_option("shared-option", "base");
452        base.options
453            .insert("stable-option".to_owned(), "stable".to_owned());
454
455        let overlay = ScanProfile::custom("overlay", ScanIntensity::Aggressive)
456            .with_description("overlay description")
457            .with_timeout(99)
458            .with_rate_limit(42)
459            .with_workers(8)
460            .add_plugin("shared-plugin")
461            .add_plugin("overlay-plugin")
462            .add_script("overlay-script")
463            .add_subscription("overlay-event")
464            .add_option("shared-option", "overlay")
465            .add_option("overlay-only", "overlay");
466        loader.register_profile(base);
467        loader.register_profile(overlay);
468
469        let merged = loader.merge_profiles("base", "overlay").unwrap();
470
471        assert_eq!(merged.name, "base_merged_with_overlay");
472        assert_eq!(merged.description, "overlay description");
473        assert_eq!(merged.scan_intensity, ScanIntensity::Aggressive);
474        assert_eq!(merged.timeout_secs, 99);
475        assert_eq!(merged.rate_limit_rps, 42);
476        assert_eq!(merged.concurrent_workers, 8);
477        assert_eq!(
478            merged.plugins_enabled,
479            vec![
480                "base-plugin".to_owned(),
481                "shared-plugin".to_owned(),
482                "overlay-plugin".to_owned(),
483            ]
484        );
485        assert_eq!(
486            merged.lua_scripts_enabled,
487            vec!["base-script".to_owned(), "overlay-script".to_owned()]
488        );
489        assert_eq!(
490            merged.event_subscriptions,
491            vec!["base-event".to_owned(), "overlay-event".to_owned()]
492        );
493        assert_eq!(merged.options["base-only"], "base");
494        assert_eq!(merged.options["stable-option"], "stable");
495        assert_eq!(merged.options["shared-option"], "overlay");
496        assert_eq!(merged.options["overlay-only"], "overlay");
497    }
498}