1use std::collections::HashMap;
5use std::fs;
6use std::path::Path;
7use std::sync::OnceLock;
8
9#[derive(Debug, Clone, Default)] pub struct YamlConfig {
12 pub verbose: Option<bool>,
14 pub no_color: Option<bool>,
15 pub output_format: Option<String>,
16 pub output_file: Option<String>,
17 pub preset: Option<String>,
18 pub threads: Option<usize>,
19 pub rate_limit: Option<u32>,
20 pub auto_persist: Option<bool>,
21
22 pub network_timeout_ms: Option<u64>,
24 pub network_max_retries: Option<usize>,
25 pub network_request_delay_ms: Option<u64>,
26 pub network_dns_resolver: Option<String>,
27 pub network_dns_timeout_ms: Option<u64>,
28
29 pub web_user_agent: Option<String>,
31 pub web_follow_redirects: Option<bool>,
32 pub web_max_redirects: Option<usize>,
33 pub web_verify_ssl: Option<bool>,
34 pub web_headers: HashMap<String, String>, pub web_timeout_secs: Option<u64>,
36
37 pub recon_subdomain_wordlist: Option<String>,
39 pub recon_passive_only: Option<bool>,
40 pub recon_dns_timeout_ms: Option<u64>,
41
42 pub db_dir: Option<String>,
44 pub db_auto_name: Option<bool>,
45 pub db_auto_persist: Option<bool>,
46 pub db_format_version: Option<u32>,
47
48 pub wordlists: HashMap<String, String>,
50
51 pub credentials: HashMap<String, HashMap<String, String>>,
53
54 pub commands: HashMap<String, HashMap<String, String>>,
56
57 pub custom: HashMap<String, String>,
59}
60
61static CACHE: OnceLock<YamlConfig> = OnceLock::new();
62
63impl YamlConfig {
64 pub fn load_from_cwd_cached() -> &'static YamlConfig {
66 CACHE.get_or_init(|| YamlConfig::load_from_cwd().unwrap_or_default())
67 }
68
69 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, String> {
71 let content =
72 fs::read_to_string(path).map_err(|e| format!("Failed to read config: {}", e))?;
73
74 Self::parse(&content)
75 }
76
77 pub fn load_from_cwd() -> Option<Self> {
79 if let Ok(config) = Self::load(".reddb.yaml") {
81 return Some(config);
82 }
83
84 if let Ok(config) = Self::load(".reddb.yml") {
86 return Some(config);
87 }
88
89 None
90 }
91
92 fn parse(content: &str) -> Result<Self, String> {
94 let mut config = YamlConfig::default(); let mut current_section: Option<String> = None;
97 let mut current_subsection: Option<String> = None; let mut _current_map_key: Option<String> = None; for line in content.lines() {
101 let trimmed = line.trim();
102
103 if trimmed.is_empty() || trimmed.starts_with('#') {
105 continue;
106 }
107
108 let indent_level = line.len() - line.trim_start().len();
110
111 if trimmed.ends_with(':') && !trimmed.contains(": ") {
113 let section_name = trimmed.trim_end_matches(':').to_string();
115
116 if indent_level == 0 {
117 current_section = Some(section_name.clone());
119 current_subsection = None; _current_map_key = None; } else if indent_level == 2 && current_section.is_some() {
122 current_subsection = Some(section_name);
124 _current_map_key = None;
125 } else if indent_level == 4 && current_subsection.is_some() {
126 _current_map_key = Some(section_name);
128 }
129 continue;
130 }
131
132 if trimmed.starts_with('-') {
134 let item = trimmed
136 .trim_start_matches('-')
137 .trim()
138 .trim_matches('"')
139 .to_string();
140 if current_section.as_deref() == Some("recon")
141 && current_subsection.as_deref() == Some("url_sources")
142 {
143 config.custom.insert(
147 format!(
148 "{}.{}.{}",
149 current_section.as_deref().unwrap_or(""),
150 current_subsection.as_deref().unwrap_or(""),
151 item
152 ),
153 "true".to_string(),
154 );
155 }
156 continue;
157 }
158
159 if let Some((key, value)) = Self::parse_key_value(trimmed) {
161 match (current_section.as_deref(), current_subsection.as_deref()) {
162 (None, None) => match key {
164 "verbose" => config.verbose = Self::parse_bool(value),
165 "no_color" | "no-color" => config.no_color = Self::parse_bool(value),
166 "output_format" => config.output_format = Some(value.to_string()),
167 "output_file" => config.output_file = Some(value.to_string()),
168 "preset" => config.preset = Some(value.to_string()),
169 "threads" => config.threads = value.parse().ok(),
170 "rate_limit" => config.rate_limit = value.parse().ok(),
171 "auto_persist" | "persist" => config.auto_persist = Self::parse_bool(value),
172 _ => {
173 config.custom.insert(key.to_string(), value.to_string());
174 }
175 },
176 (Some("network"), None) => match key {
178 "timeout_ms" => config.network_timeout_ms = value.parse().ok(),
179 "max_retries" => config.network_max_retries = value.parse().ok(),
180 "request_delay_ms" => config.network_request_delay_ms = value.parse().ok(),
181 "dns_resolver" => config.network_dns_resolver = Some(value.to_string()),
182 "dns_timeout_ms" => config.network_dns_timeout_ms = value.parse().ok(),
183 _ => {
184 config
185 .custom
186 .insert(format!("network.{}", key), value.to_string());
187 }
188 },
189 (Some("web"), Some("headers")) => {
190 config
192 .web_headers
193 .insert(key.to_string(), value.to_string());
194 }
195 (Some("web"), None) => match key {
196 "user_agent" => config.web_user_agent = Some(value.to_string()),
197 "follow_redirects" => config.web_follow_redirects = Self::parse_bool(value),
198 "max_redirects" => config.web_max_redirects = value.parse().ok(),
199 "verify_ssl" => config.web_verify_ssl = Self::parse_bool(value),
200 "timeout_secs" => config.web_timeout_secs = value.parse().ok(),
201 _ => {
202 config
203 .custom
204 .insert(format!("web.{}", key), value.to_string());
205 }
206 },
207 (Some("recon"), None) => match key {
208 "subdomain_wordlist" => {
209 config.recon_subdomain_wordlist = Some(value.to_string())
210 }
211 "passive_only" => config.recon_passive_only = Self::parse_bool(value),
212 "dns_timeout_ms" => config.recon_dns_timeout_ms = value.parse().ok(),
213 _ => {
214 config
215 .custom
216 .insert(format!("recon.{}", key), value.to_string());
217 }
218 },
219 (Some("database"), None) => match key {
220 "auto_name" => config.db_auto_name = Self::parse_bool(value),
221 "auto_persist" => config.db_auto_persist = Self::parse_bool(value),
222 "db_dir" => config.db_dir = Some(value.to_string()),
223 "format_version" => config.db_format_version = value.parse().ok(),
224 _ => {
225 config
226 .custom
227 .insert(format!("database.{}", key), value.to_string());
228 }
229 },
230 (Some("wordlists"), None) => {
231 config.wordlists.insert(key.to_string(), value.to_string());
232 }
233 (Some("credentials"), Some(service_name)) => {
234 config
235 .credentials
236 .entry(service_name.to_string())
237 .or_insert_with(HashMap::new)
238 .insert(key.to_string(), value.to_string());
239 }
240 (Some("commands"), Some(cmd)) => {
241 config
242 .commands
243 .entry(cmd.to_string())
244 .or_insert_with(HashMap::new)
245 .insert(key.to_string(), value.to_string());
246 }
247 _ => {
249 config.custom.insert(key.to_string(), value.to_string());
250 }
251 }
252 }
253 }
254
255 Ok(config)
256 }
257
258 fn parse_key_value(line: &str) -> Option<(&str, &str)> {
260 let mut parts = line.splitn(2, ':');
261 let key = parts.next()?.trim();
262 let value = parts.next()?.trim();
263
264 let value = value.trim_matches(|c| c == '"' || c == '\'');
266
267 Some((key, value))
268 }
269
270 fn parse_bool(value: &str) -> Option<bool> {
271 match value.to_lowercase().as_str() {
272 "true" | "yes" | "1" => Some(true),
273 "false" | "no" | "0" => Some(false),
274 _ => None,
275 }
276 }
277
278 pub fn get_command_flag(
281 &self,
282 domain: &str,
283 resource: &str,
284 verb: &str,
285 flag: &str,
286 ) -> Option<String> {
287 let full_path = format!("{}.{}.{}", domain, resource, verb);
289 if let Some(flags) = self.commands.get(&full_path) {
290 if let Some(value) = flags.get(flag) {
291 return Some(value.clone());
292 }
293 }
294
295 let resource_path = format!("{}.{}", domain, resource);
297 if let Some(flags) = self.commands.get(&resource_path) {
298 if let Some(value) = flags.get(flag) {
299 return Some(value.clone());
300 }
301 }
302
303 if let Some(flags) = self.commands.get(domain) {
305 if let Some(value) = flags.get(flag) {
306 return Some(value.clone());
307 }
308 }
309
310 None
311 }
312
313 pub fn command_flags(
315 &self,
316 domain: &str,
317 resource: &str,
318 verb: &str,
319 ) -> HashMap<String, String> {
320 let mut merged = HashMap::new();
321
322 if domain.is_empty() {
323 return merged;
324 }
325
326 if let Some(flags) = self.commands.get(domain) {
327 merged.extend(flags.clone());
328 }
329
330 if !resource.is_empty() {
331 let resource_path = format!("{}.{}", domain, resource);
332 if let Some(flags) = self.commands.get(&resource_path) {
333 merged.extend(flags.clone());
334 }
335 }
336
337 if !resource.is_empty() && !verb.is_empty() {
338 let full_path = format!("{}.{}.{}", domain, resource, verb);
339 if let Some(flags) = self.commands.get(&full_path) {
340 merged.extend(flags.clone());
341 }
342 }
343
344 merged
345 }
346
347 pub fn has_command_flag(&self, domain: &str, resource: &str, verb: &str, flag: &str) -> bool {
349 if let Some(value) = self.get_command_flag(domain, resource, verb, flag) {
350 Self::parse_bool(&value).unwrap_or(false)
351 } else {
352 false
353 }
354 }
355
356 pub fn get_credential(&self, service: &str, key: &str) -> Option<String> {
358 self.credentials
359 .get(service)
360 .and_then(|service_creds| service_creds.get(key).cloned())
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_parse_simple() {
370 let yaml = r###"#
371# RedDB config
372verbose: true
373output_format: json
374threads: 20
375rate_limit: 10
376#"###;
377
378 let config = YamlConfig::parse(yaml).unwrap();
379 assert_eq!(config.verbose, Some(true));
380 assert_eq!(config.output_format, Some("json".to_string()));
381 assert_eq!(config.threads, Some(20));
382 assert_eq!(config.rate_limit, Some(10));
383 }
384
385 #[test]
386 fn test_parse_network_config() {
387 let yaml = r###"#
388network:
389 timeout_ms: 10000
390 dns_resolver: "1.1.1.1"
391#"###;
392 let config = YamlConfig::parse(yaml).unwrap();
393 assert_eq!(config.network_timeout_ms, Some(10000));
394 assert_eq!(config.network_dns_resolver, Some("1.1.1.1".to_string()));
395 }
396
397 #[test]
398 fn test_parse_web_config() {
399 let yaml = r###"#
400web:
401 user_agent: "MyCustomUA"
402 follow_redirects: false
403 headers:
404 X-API-Key: "abc"
405 Accept: "application/json"
406#"###;
407 let config = YamlConfig::parse(yaml).unwrap();
408 assert_eq!(config.web_user_agent, Some("MyCustomUA".to_string()));
409 assert_eq!(config.web_follow_redirects, Some(false));
410 assert_eq!(
411 config.web_headers.get("X-API-Key"),
412 Some(&"abc".to_string())
413 );
414 }
415
416 #[test]
417 fn test_parse_wordlists() {
418 let yaml = r###"#
419wordlists:
420 subdomains: /usr/share/wordlists/subdomains.txt
421 directories: /usr/share/wordlists/dirs.txt
422#"###;
423
424 let config = YamlConfig::parse(yaml).unwrap();
425 assert_eq!(config.wordlists.len(), 2);
426 assert!(config.wordlists.contains_key("subdomains"));
427 }
428
429 #[test]
430 fn test_parse_credentials() {
431 let yaml = r###"#
432credentials:
433 hibp:
434 api_key: "my_hibp_key"
435 shodan:
436 api_key: "my_shodan_key"
437 username: "user"
438#"###;
439 let config = YamlConfig::parse(yaml).unwrap();
440 assert!(config.credentials.contains_key("hibp"));
441 assert_eq!(
442 config.credentials.get("hibp").unwrap().get("api_key"),
443 Some(&"my_hibp_key".to_string())
444 );
445 assert_eq!(
446 config.get_credential("shodan", "api_key"),
447 Some("my_shodan_key".to_string())
448 );
449 }
450
451 #[test]
452 fn test_parse_bool_values() {
453 let yaml = r###"#
454verbose: yes
455no_color: 0
456auto_persist: "true"
457#"###;
458 let config = YamlConfig::parse(yaml).unwrap();
459 assert_eq!(config.verbose, Some(true));
460 assert_eq!(config.no_color, Some(false));
461 assert_eq!(config.auto_persist, Some(true));
462 }
463
464 #[test]
465 fn test_parse_command_specific_flags() {
466 let yaml = r###"#
467commands:
468 recon.domain.subdomains:
469 threads: 50
470 passive_only: true
471 web.fuzz:
472 rate_limit: 10
473#"###;
474 let config = YamlConfig::parse(yaml).unwrap();
475 assert_eq!(
476 config.get_command_flag("recon", "domain", "subdomains", "threads"),
477 Some("50".to_string())
478 );
479 assert!(config.has_command_flag("recon", "domain", "subdomains", "passive_only"));
480 assert_eq!(
481 config.get_command_flag("web", "fuzz", "run", "rate_limit"),
482 Some("10".to_string())
483 );
484 }
485}