Skip to main content

termivar_scanner/
config.rs

1//! Caller-supplied scan-configuration records.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** opt-in via `platform-models`.
6//! - **Execution:** host/library data model only; no repository runtime caller.
7//! - **Default `termivar scan`:** no.
8//! - **Support:** experimental/scaffold.
9//!
10//! The repository does not provide behavior presets and does not apply these
11//! values to either scanner runtime. An integrating host must define and enforce
12//! the meaning of every field. See `docs/internals/runtime-map.md`.
13
14use serde::{Deserialize, Serialize};
15
16use crate::lua_config::LuaEngineConfig;
17
18/// Caller-assigned intensity label; it grants no scanner behavior by itself.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum ScanIntensity {
21    Light,
22    Normal,
23    Aggressive,
24    Stealth,
25}
26
27impl ScanIntensity {
28    #[must_use]
29    pub fn as_str(&self) -> &str {
30        match self {
31            Self::Light => "light",
32            Self::Normal => "normal",
33            Self::Aggressive => "aggressive",
34            Self::Stealth => "stealth",
35        }
36    }
37}
38
39/// Uninterpreted host configuration record with basic value validation.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ScanConfig {
42    pub intensity: ScanIntensity,
43    pub timeout_secs: u64,
44    pub max_concurrency: u32,
45    #[serde(with = "positive_f32")]
46    pub rate_limit: f32,
47    pub max_payload_size: u64,
48    /// Historical phase identifiers supplied by a host; this module does not run them.
49    pub phases: Vec<u8>,
50    pub lua_engine: LuaEngineConfig,
51}
52
53mod positive_f32 {
54    use serde::{Deserialize, Deserializer, Serializer};
55
56    pub fn serialize<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
57    where
58        S: Serializer,
59    {
60        if value.is_finite() && *value > 0.0 {
61            serializer.serialize_f32(*value)
62        } else {
63            Err(serde::ser::Error::custom(
64                "rate limit must be finite and greater than zero",
65            ))
66        }
67    }
68
69    pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
70    where
71        D: Deserializer<'de>,
72    {
73        let value = f32::deserialize(deserializer)?;
74        if value.is_finite() && value > 0.0 {
75            Ok(value)
76        } else {
77            Err(serde::de::Error::custom(
78                "rate limit must be finite and greater than zero",
79            ))
80        }
81    }
82}
83
84impl ScanConfig {
85    /// Validates the record envelope without authorizing or executing a scan.
86    pub fn validate(&self) -> Result<(), String> {
87        if self.timeout_secs == 0 {
88            return Err("Timeout must be > 0".to_string());
89        }
90        if self.max_concurrency == 0 {
91            return Err("Max concurrency must be > 0".to_string());
92        }
93        if !self.rate_limit.is_finite() || self.rate_limit <= 0.0 {
94            return Err("Rate limit must be finite and > 0".to_string());
95        }
96        if self.max_payload_size == 0 {
97            return Err("Max payload size must be > 0".to_string());
98        }
99        if self.phases.is_empty() {
100            return Err("At least one phase identifier must be supplied".to_string());
101        }
102        for phase in &self.phases {
103            if !(1..=9).contains(phase) {
104                return Err(format!("Invalid phase number: {phase}"));
105            }
106        }
107        self.lua_engine
108            .validate()
109            .map_err(|error| format!("Invalid Lua engine configuration: {error}"))?;
110        Ok(())
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn valid_record() -> ScanConfig {
119        ScanConfig {
120            intensity: ScanIntensity::Normal,
121            timeout_secs: 5,
122            max_concurrency: 4,
123            rate_limit: 10.0,
124            max_payload_size: 1024,
125            phases: vec![1, 2],
126            lua_engine: LuaEngineConfig::minimal(),
127        }
128    }
129
130    #[test]
131    fn intensity_is_only_a_stable_label() {
132        assert_eq!(ScanIntensity::Light.as_str(), "light");
133        assert_eq!(ScanIntensity::Normal.as_str(), "normal");
134        assert_eq!(ScanIntensity::Aggressive.as_str(), "aggressive");
135        assert_eq!(ScanIntensity::Stealth.as_str(), "stealth");
136    }
137
138    #[test]
139    fn valid_host_record_passes_envelope_validation() {
140        assert!(valid_record().validate().is_ok());
141    }
142
143    #[test]
144    fn invalid_limits_and_phase_ids_fail_closed() {
145        let mut config = valid_record();
146        config.timeout_secs = 0;
147        assert!(config.validate().is_err());
148
149        let mut config = valid_record();
150        config.phases = vec![0];
151        assert!(config.validate().is_err());
152
153        let mut config = valid_record();
154        config.phases.clear();
155        assert_eq!(
156            config.validate().unwrap_err(),
157            "At least one phase identifier must be supplied"
158        );
159
160        for invalid_rate in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -1.0] {
161            let mut config = valid_record();
162            config.rate_limit = invalid_rate;
163            assert_eq!(
164                config.validate().unwrap_err(),
165                "Rate limit must be finite and > 0"
166            );
167        }
168    }
169
170    #[test]
171    fn nested_lua_limits_are_validated() {
172        let mut config = valid_record();
173        config.lua_engine.default_timeout_ms = 0;
174        assert_eq!(
175            config.validate().unwrap_err(),
176            "Invalid Lua engine configuration: default_timeout_ms must be nonzero"
177        );
178    }
179
180    #[test]
181    fn wire_rejects_invalid_rate_and_uses_fixed_width_limits() {
182        let mut invalid = valid_record();
183        invalid.rate_limit = f32::NAN;
184        assert!(serde_json::to_string(&invalid).is_err());
185
186        let record = valid_record();
187        let encoded = serde_json::to_string(&record).expect("valid record serializes");
188        let decoded: ScanConfig = serde_json::from_str(&encoded).expect("record round-trips");
189        assert_eq!(decoded.max_concurrency, 4_u32);
190        assert_eq!(decoded.max_payload_size, 1_024_u64);
191
192        let mut invalid_wire = serde_json::to_value(record).unwrap();
193        invalid_wire["rate_limit"] = serde_json::json!(-1.0);
194        assert!(serde_json::from_value::<ScanConfig>(invalid_wire).is_err());
195    }
196}