1use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::env;
15use std::path::Path;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Config {
20 pub target: String,
22
23 pub intensity: ScanIntensity,
25
26 pub timeout_secs: u64,
28
29 pub num_threads: u32,
31
32 pub rate_limit_rps: u32,
34
35 pub aggressive: bool,
37
38 pub max_payload_size: usize,
40
41 pub options: HashMap<String, String>,
43
44 pub verify_ssl: bool,
46
47 pub connect_timeout_secs: u64,
49}
50
51#[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#[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 pub fn validate(&self) -> Result<(), ConfigError> {
109 if self.target.is_empty() {
111 return Err(ConfigError::TargetEmpty);
112 }
113
114 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 if self.timeout_secs == 0 {
127 return Err(ConfigError::TimeoutZero);
128 }
129
130 if self.num_threads == 0 {
132 return Err(ConfigError::NumThreadsZero);
133 }
134
135 if self.rate_limit_rps > 1_000_000 {
137 return Err(ConfigError::RateLimitNegative);
138 }
139
140 if self.max_payload_size == 0 {
142 return Err(ConfigError::MaxPayloadSizeZero);
143 }
144
145 if self.connect_timeout_secs == 0 {
147 return Err(ConfigError::ConnectTimeoutZero);
148 }
149
150 Ok(())
151 }
152
153 pub fn builder(target: impl Into<String>) -> ConfigBuilder {
155 ConfigBuilder::new(target)
156 }
157
158 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 config.apply_env_overrides()?;
168 config.validate()?;
169
170 Ok(config)
171 }
172
173 pub fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
175 if let Ok(target) = env::var("VENOM_TARGET") {
177 self.target = target;
178 }
179
180 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 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 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 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 if let Ok(aggressive) = env::var("VENOM_AGGRESSIVE") {
213 self.aggressive = aggressive.to_lowercase() == "true" || aggressive == "1";
214 }
215
216 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 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 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
254pub 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 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 pub fn intensity(mut self, intensity: ScanIntensity) -> Self {
287 self.intensity = intensity;
288 self
289 }
290
291 pub fn timeout(mut self, secs: u64) -> Self {
293 self.timeout_secs = secs;
294 self
295 }
296
297 pub fn threads(mut self, count: u32) -> Self {
299 self.num_threads = count;
300 self
301 }
302
303 pub fn rate_limit(mut self, rps: u32) -> Self {
305 self.rate_limit_rps = rps;
306 self
307 }
308
309 pub fn aggressive(mut self, enabled: bool) -> Self {
311 self.aggressive = enabled;
312 self
313 }
314
315 pub fn max_payload_size(mut self, size: usize) -> Self {
317 self.max_payload_size = size;
318 self
319 }
320
321 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 pub fn verify_ssl(mut self, verify: bool) -> Self {
329 self.verify_ssl = verify;
330 self
331 }
332
333 pub fn connect_timeout(mut self, secs: u64) -> Self {
335 self.connect_timeout_secs = secs;
336 self
337 }
338
339 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}