Skip to main content

reddb_server/config/
yaml.rs

1// YAML config parser - ZERO external dependencies!
2// Implements minimal YAML parser for .reddb.yaml
3
4use std::collections::HashMap;
5use std::fs;
6use std::path::Path;
7use std::sync::OnceLock;
8
9/// Parsed configuration from .reddb.yaml
10#[derive(Debug, Clone, Default)] // Derive Default for easier initialization
11pub struct YamlConfig {
12    // --- Global/Core Settings ---
13    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    // --- Network Configuration ---
23    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    // --- Web Configuration ---
30    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>, // Already a HashMap
35    pub web_timeout_secs: Option<u64>,
36
37    // --- Reconnaissance Configuration ---
38    pub recon_subdomain_wordlist: Option<String>,
39    pub recon_passive_only: Option<bool>,
40    pub recon_dns_timeout_ms: Option<u64>,
41
42    // --- Database Configuration ---
43    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    // --- Wordlists ---
49    pub wordlists: HashMap<String, String>,
50
51    // --- Credentials (e.g., api_keys for external services) ---
52    pub credentials: HashMap<String, HashMap<String, String>>,
53
54    // --- Command-specific overrides ---
55    pub commands: HashMap<String, HashMap<String, String>>,
56
57    // --- Custom/Unknown fields ---
58    pub custom: HashMap<String, String>,
59}
60
61static CACHE: OnceLock<YamlConfig> = OnceLock::new();
62
63impl YamlConfig {
64    /// Load from current directory once and cache the result.
65    pub fn load_from_cwd_cached() -> &'static YamlConfig {
66        CACHE.get_or_init(|| YamlConfig::load_from_cwd().unwrap_or_default())
67    }
68
69    /// Load config from file
70    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    /// Try to load from current directory
78    pub fn load_from_cwd() -> Option<Self> {
79        // Try .reddb.yaml first
80        if let Ok(config) = Self::load(".reddb.yaml") {
81            return Some(config);
82        }
83
84        // Try .reddb.yml
85        if let Ok(config) = Self::load(".reddb.yml") {
86            return Some(config);
87        }
88
89        None
90    }
91
92    /// Parse YAML content (minimal parser)
93    fn parse(content: &str) -> Result<Self, String> {
94        let mut config = YamlConfig::default(); // Use default for easier base
95
96        let mut current_section: Option<String> = None;
97        let mut current_subsection: Option<String> = None; // For credentials/commands sub-sections
98        let mut _current_map_key: Option<String> = None; // For headers or other maps
99
100        for line in content.lines() {
101            let trimmed = line.trim();
102
103            // Skip comments and empty lines
104            if trimmed.is_empty() || trimmed.starts_with('#') {
105                continue;
106            }
107
108            // Detect indentation for nested sections
109            let indent_level = line.len() - line.trim_start().len();
110
111            // Check for section header (ends with :)
112            if trimmed.ends_with(':') && !trimmed.contains(": ") {
113                // Avoid key: value: like in headers: X-Custom: value
114                let section_name = trimmed.trim_end_matches(':').to_string();
115
116                if indent_level == 0 {
117                    // Top-level section
118                    current_section = Some(section_name.clone());
119                    current_subsection = None; // Reset subsection on new top-level
120                    _current_map_key = None; // Reset map key
121                } else if indent_level == 2 && current_section.is_some() {
122                    // Nested section (for commands or credentials sub-sections)
123                    current_subsection = Some(section_name);
124                    _current_map_key = None;
125                } else if indent_level == 4 && current_subsection.is_some() {
126                    // Deeply nested, usually key-value pairs inside a map
127                    _current_map_key = Some(section_name);
128                }
129                continue;
130            }
131
132            // Handle array values (e.g., url_sources)
133            if trimmed.starts_with('-') {
134                // This is an item in a list, like `- item`
135                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                    // How to add to a Vec in Config? This needs a Vec in Config.
144                    // For now, this parser doesn't collect lists, but a proper YamlConfig would have Vec<String> fields.
145                    // We'll add this to custom for now as a workaround for simple config.
146                    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            // Parse key-value pairs
160            if let Some((key, value)) = Self::parse_key_value(trimmed) {
161                match (current_section.as_deref(), current_subsection.as_deref()) {
162                    // Global settings
163                    (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                    // Specific sections
177                    (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                        // Specific handling for web.headers map
191                        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                    // Catch-all for unknown sections or malformed entries
248                    _ => {
249                        config.custom.insert(key.to_string(), value.to_string());
250                    }
251                }
252            }
253        }
254
255        Ok(config)
256    }
257
258    /// Parse "key: value" line
259    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        // Remove quotes if present
265        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    /// Get command-specific flag value
279    /// Tries: domain.resource.verb -> domain.resource -> domain
280    pub fn get_command_flag(
281        &self,
282        domain: &str,
283        resource: &str,
284        verb: &str,
285        flag: &str,
286    ) -> Option<String> {
287        // Try full path: network.nc.listen
288        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        // Try resource level: network.nc
296        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        // Try domain level: network
304        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    /// Collect all command-level flags (domain/resource/verb) with specificity overrides.
314    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    /// Check if command flag is set to true
348    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    /// Get a credential value for a given service and key
357    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}