Skip to main content

termivar_core/
config.rs

1//! Historical configuration compatibility facade.
2//!
3//! This module is available only with the non-default `legacy-contracts`
4//! feature. No repository runtime consumes this configuration or its
5//! environment overrides; scanner hosts use their feature-specific contracts.
6//!
7//! ## Deprecated compatibility surface
8//!
9//! These types remain solely for the pinned pre-1.0 patch-compatibility
10//! baseline. New integrations should not adopt them.
11
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::env;
15use std::path::Path;
16
17/// Historical scan configuration retained for compatibility.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Config {
20    /// Target URL/IP to scan
21    pub target: String,
22
23    /// Scan intensity (light, normal, aggressive, stealth)
24    pub intensity: ScanIntensity,
25
26    /// Scan timeout in seconds (must be > 0)
27    pub timeout_secs: u64,
28
29    /// Maximum concurrent workers (must be > 0)
30    pub num_threads: u32,
31
32    /// Requests per second rate limit (>= 0)
33    pub rate_limit_rps: u32,
34
35    /// Enable aggressive scanning
36    pub aggressive: bool,
37
38    /// Maximum payload size in bytes
39    pub max_payload_size: usize,
40
41    /// Custom options
42    pub options: HashMap<String, String>,
43
44    /// Enable SSL verification
45    pub verify_ssl: bool,
46
47    /// Connection timeout in seconds
48    pub connect_timeout_secs: u64,
49}
50
51/// Scan intensity levels
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
53pub enum ScanIntensity {
54    #[serde(rename = "light")]
55    Light,
56    #[serde(rename = "normal")]
57    #[default]
58    Normal,
59    #[serde(rename = "aggressive")]
60    Aggressive,
61    #[serde(rename = "stealth")]
62    Stealth,
63}
64
65impl ScanIntensity {
66    pub fn as_str(&self) -> &str {
67        match self {
68            ScanIntensity::Light => "light",
69            ScanIntensity::Normal => "normal",
70            ScanIntensity::Aggressive => "aggressive",
71            ScanIntensity::Stealth => "stealth",
72        }
73    }
74}
75
76/// Validation errors
77#[derive(Debug, Clone)]
78pub enum ConfigError {
79    TargetEmpty,
80    TimeoutZero,
81    NumThreadsZero,
82    RateLimitNegative,
83    MaxPayloadSizeZero,
84    ConnectTimeoutZero,
85    InvalidTarget(String),
86    ParseError(String),
87}
88
89impl std::fmt::Display for ConfigError {
90    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
91        match self {
92            ConfigError::TargetEmpty => write!(f, "Target cannot be empty"),
93            ConfigError::TimeoutZero => write!(f, "Timeout must be greater than 0"),
94            ConfigError::NumThreadsZero => write!(f, "Number of threads must be greater than 0"),
95            ConfigError::RateLimitNegative => write!(f, "Rate limit cannot be negative"),
96            ConfigError::MaxPayloadSizeZero => write!(f, "Max payload size must be greater than 0"),
97            ConfigError::ConnectTimeoutZero => write!(f, "Connect timeout must be greater than 0"),
98            ConfigError::InvalidTarget(msg) => write!(f, "Invalid target: {msg}"),
99            ConfigError::ParseError(msg) => write!(f, "Parse error: {msg}"),
100        }
101    }
102}
103
104impl std::error::Error for ConfigError {}
105
106impl Config {
107    /// Validates configuration
108    pub fn validate(&self) -> Result<(), ConfigError> {
109        // Target validation
110        if self.target.is_empty() {
111            return Err(ConfigError::TargetEmpty);
112        }
113
114        // Basic URL validation (should start with http:// or https://)
115        if !self.target.starts_with("http://")
116            && !self.target.starts_with("https://")
117            && !self.target.contains(':')
118            && !self.target.contains('.')
119        {
120            return Err(ConfigError::InvalidTarget(
121                "Must be valid URL or IP address".to_string(),
122            ));
123        }
124
125        // Timeout validation
126        if self.timeout_secs == 0 {
127            return Err(ConfigError::TimeoutZero);
128        }
129
130        // Thread validation
131        if self.num_threads == 0 {
132            return Err(ConfigError::NumThreadsZero);
133        }
134
135        // Rate limit validation
136        if self.rate_limit_rps > 1_000_000 {
137            return Err(ConfigError::RateLimitNegative);
138        }
139
140        // Payload size validation
141        if self.max_payload_size == 0 {
142            return Err(ConfigError::MaxPayloadSizeZero);
143        }
144
145        // Connect timeout validation
146        if self.connect_timeout_secs == 0 {
147            return Err(ConfigError::ConnectTimeoutZero);
148        }
149
150        Ok(())
151    }
152
153    /// Creates a builder for fluent configuration
154    pub fn builder(target: impl Into<String>) -> ConfigBuilder {
155        ConfigBuilder::new(target)
156    }
157
158    /// Loads configuration from TOML file with environment overrides
159    pub fn from_toml(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
160        let content = std::fs::read_to_string(path)
161            .map_err(|e| ConfigError::ParseError(format!("Failed to read file: {e}")))?;
162
163        let mut config: Config = toml::from_str(&content)
164            .map_err(|e| ConfigError::ParseError(format!("Failed to parse TOML: {e}")))?;
165
166        // Apply environment overrides
167        config.apply_env_overrides()?;
168        config.validate()?;
169
170        Ok(config)
171    }
172
173    /// Applies environment variable overrides
174    pub fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
175        // Override target
176        if let Ok(target) = env::var("VENOM_TARGET") {
177            self.target = target;
178        }
179
180        // Override timeout
181        if let Ok(timeout) = env::var("VENOM_TIMEOUT_SECS") {
182            self.timeout_secs = timeout
183                .parse()
184                .map_err(|_| ConfigError::ParseError("Invalid VENOM_TIMEOUT_SECS".to_string()))?;
185        }
186
187        // Override num_threads
188        if let Ok(threads) = env::var("VENOM_NUM_THREADS") {
189            self.num_threads = threads
190                .parse()
191                .map_err(|_| ConfigError::ParseError("Invalid VENOM_NUM_THREADS".to_string()))?;
192        }
193
194        // Override rate_limit_rps
195        if let Ok(rate_limit) = env::var("VENOM_RATE_LIMIT_RPS") {
196            self.rate_limit_rps = rate_limit
197                .parse()
198                .map_err(|_| ConfigError::ParseError("Invalid VENOM_RATE_LIMIT_RPS".to_string()))?;
199        }
200
201        // Override intensity
202        if let Ok(intensity) = env::var("VENOM_INTENSITY") {
203            self.intensity = match intensity.as_str() {
204                "light" => ScanIntensity::Light,
205                "aggressive" => ScanIntensity::Aggressive,
206                "stealth" => ScanIntensity::Stealth,
207                _ => ScanIntensity::Normal,
208            };
209        }
210
211        // Override aggressive flag
212        if let Ok(aggressive) = env::var("VENOM_AGGRESSIVE") {
213            self.aggressive = aggressive.to_lowercase() == "true" || aggressive == "1";
214        }
215
216        // Override verify_ssl
217        if let Ok(verify) = env::var("VENOM_VERIFY_SSL") {
218            self.verify_ssl = verify.to_lowercase() == "true" || verify == "1";
219        }
220
221        Ok(())
222    }
223
224    /// Exports configuration as TOML string
225    pub fn to_toml(&self) -> Result<String, ConfigError> {
226        toml::to_string_pretty(self)
227            .map_err(|e| ConfigError::ParseError(format!("Failed to serialize to TOML: {e}")))
228    }
229
230    /// Exports configuration as JSON string
231    pub fn to_json(&self) -> Result<String, ConfigError> {
232        serde_json::to_string_pretty(self)
233            .map_err(|e| ConfigError::ParseError(format!("Failed to serialize to JSON: {e}")))
234    }
235}
236
237impl Default for Config {
238    fn default() -> Self {
239        Self {
240            target: String::new(),
241            intensity: ScanIntensity::Normal,
242            timeout_secs: 300,
243            num_threads: 4,
244            rate_limit_rps: 50,
245            aggressive: false,
246            max_payload_size: 10240,
247            options: HashMap::new(),
248            verify_ssl: true,
249            connect_timeout_secs: 30,
250        }
251    }
252}
253
254/// Builder for creating Config with validation
255pub struct ConfigBuilder {
256    target: String,
257    intensity: ScanIntensity,
258    timeout_secs: u64,
259    num_threads: u32,
260    rate_limit_rps: u32,
261    aggressive: bool,
262    max_payload_size: usize,
263    options: HashMap<String, String>,
264    verify_ssl: bool,
265    connect_timeout_secs: u64,
266}
267
268impl ConfigBuilder {
269    /// Creates new builder with required target
270    pub fn new(target: impl Into<String>) -> Self {
271        Self {
272            target: target.into(),
273            intensity: ScanIntensity::Normal,
274            timeout_secs: 300,
275            num_threads: 4,
276            rate_limit_rps: 50,
277            aggressive: false,
278            max_payload_size: 10240,
279            options: HashMap::new(),
280            verify_ssl: true,
281            connect_timeout_secs: 30,
282        }
283    }
284
285    /// Sets scan intensity
286    pub fn intensity(mut self, intensity: ScanIntensity) -> Self {
287        self.intensity = intensity;
288        self
289    }
290
291    /// Sets timeout in seconds
292    pub fn timeout(mut self, secs: u64) -> Self {
293        self.timeout_secs = secs;
294        self
295    }
296
297    /// Sets number of threads
298    pub fn threads(mut self, count: u32) -> Self {
299        self.num_threads = count;
300        self
301    }
302
303    /// Sets rate limit (requests per second)
304    pub fn rate_limit(mut self, rps: u32) -> Self {
305        self.rate_limit_rps = rps;
306        self
307    }
308
309    /// Enables aggressive scanning
310    pub fn aggressive(mut self, enabled: bool) -> Self {
311        self.aggressive = enabled;
312        self
313    }
314
315    /// Sets maximum payload size
316    pub fn max_payload_size(mut self, size: usize) -> Self {
317        self.max_payload_size = size;
318        self
319    }
320
321    /// Adds custom option
322    pub fn option(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
323        self.options.insert(key.into(), value.into());
324        self
325    }
326
327    /// Sets SSL verification
328    pub fn verify_ssl(mut self, verify: bool) -> Self {
329        self.verify_ssl = verify;
330        self
331    }
332
333    /// Sets connection timeout
334    pub fn connect_timeout(mut self, secs: u64) -> Self {
335        self.connect_timeout_secs = secs;
336        self
337    }
338
339    /// Builds and validates configuration
340    pub fn build(self) -> Result<Config, ConfigError> {
341        let config = Config {
342            target: self.target,
343            intensity: self.intensity,
344            timeout_secs: self.timeout_secs,
345            num_threads: self.num_threads,
346            rate_limit_rps: self.rate_limit_rps,
347            aggressive: self.aggressive,
348            max_payload_size: self.max_payload_size,
349            options: self.options,
350            verify_ssl: self.verify_ssl,
351            connect_timeout_secs: self.connect_timeout_secs,
352        };
353
354        config.validate()?;
355        Ok(config)
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn test_config_validation_empty_target() {
365        let config = Config {
366            target: String::new(),
367            ..Default::default()
368        };
369        assert!(config.validate().is_err());
370    }
371
372    #[test]
373    fn test_config_validation_zero_timeout() {
374        let config = Config {
375            target: "http://localhost".to_string(),
376            timeout_secs: 0,
377            ..Default::default()
378        };
379        assert!(config.validate().is_err());
380    }
381
382    #[test]
383    fn test_config_validation_zero_threads() {
384        let config = Config {
385            target: "http://localhost".to_string(),
386            num_threads: 0,
387            ..Default::default()
388        };
389        assert!(config.validate().is_err());
390    }
391
392    #[test]
393    fn test_config_validation_valid() {
394        let config = Config {
395            target: "http://localhost:8080".to_string(),
396            timeout_secs: 300,
397            num_threads: 4,
398            ..Default::default()
399        };
400        assert!(config.validate().is_ok());
401    }
402
403    #[test]
404    fn test_builder_basic() {
405        let config = Config::builder("http://localhost")
406            .timeout(600)
407            .threads(8)
408            .build()
409            .unwrap();
410
411        assert_eq!(config.target, "http://localhost");
412        assert_eq!(config.timeout_secs, 600);
413        assert_eq!(config.num_threads, 8);
414    }
415
416    #[test]
417    fn test_builder_with_options() {
418        let config = Config::builder("http://target.com")
419            .intensity(ScanIntensity::Aggressive)
420            .aggressive(true)
421            .option("custom_key", "custom_value")
422            .build()
423            .unwrap();
424
425        assert_eq!(config.intensity, ScanIntensity::Aggressive);
426        assert!(config.aggressive);
427        assert_eq!(
428            config.options.get("custom_key"),
429            Some(&"custom_value".to_string())
430        );
431    }
432
433    #[test]
434    fn test_builder_validation_fails() {
435        let result = Config::builder("http://localhost").timeout(0).build();
436
437        assert!(result.is_err());
438    }
439
440    #[test]
441    fn test_default_config() {
442        let config = Config::default();
443        assert_eq!(config.timeout_secs, 300);
444        assert_eq!(config.num_threads, 4);
445        assert_eq!(config.rate_limit_rps, 50);
446        assert_eq!(config.intensity, ScanIntensity::Normal);
447    }
448
449    #[test]
450    fn test_scan_intensity() {
451        assert_eq!(ScanIntensity::Light.as_str(), "light");
452        assert_eq!(ScanIntensity::Aggressive.as_str(), "aggressive");
453        assert_eq!(ScanIntensity::Stealth.as_str(), "stealth");
454    }
455
456    #[test]
457    fn test_config_to_toml() {
458        let config = Config::builder("http://localhost")
459            .timeout(300)
460            .threads(4)
461            .build()
462            .unwrap();
463
464        let toml = config.to_toml();
465        assert!(toml.is_ok());
466        let toml_str = toml.unwrap();
467        assert!(toml_str.contains("http://localhost"));
468    }
469
470    #[test]
471    fn test_config_to_json() {
472        let config = Config::builder("http://localhost")
473            .timeout(300)
474            .threads(4)
475            .build()
476            .unwrap();
477
478        let json = config.to_json();
479        assert!(json.is_ok());
480        let json_str = json.unwrap();
481        assert!(json_str.contains("http://localhost"));
482    }
483
484    #[test]
485    fn test_config_error_display() {
486        let err = ConfigError::TargetEmpty;
487        assert_eq!(err.to_string(), "Target cannot be empty");
488        assert_eq!(
489            ConfigError::InvalidTarget("invalid.example".to_owned()).to_string(),
490            "Invalid target: invalid.example"
491        );
492        assert_eq!(
493            ConfigError::ParseError("invalid input".to_owned()).to_string(),
494            "Parse error: invalid input"
495        );
496    }
497
498    #[test]
499    fn test_config_file_errors_remain_typed_and_contextual() {
500        let directory = tempfile::tempdir().unwrap();
501        let missing = Config::from_toml(directory.path().join("missing.toml")).unwrap_err();
502        assert!(matches!(missing, ConfigError::ParseError(_)));
503        assert!(missing.to_string().contains("Failed to read file"));
504
505        let malformed_path = directory.path().join("malformed.toml");
506        std::fs::write(&malformed_path, "target = [").unwrap();
507        let malformed = Config::from_toml(malformed_path).unwrap_err();
508        assert!(matches!(malformed, ConfigError::ParseError(_)));
509        assert!(malformed.to_string().contains("Failed to parse TOML"));
510    }
511}