Skip to main content

backbone_core/config/
schema.rs

1//! Configuration validation and schema
2//!
3//! Provides comprehensive validation for all configuration sections.
4
5use super::{BackboneConfig, ConfigError, ConfigResult};
6use thiserror::Error;
7
8/// Configuration validation error
9#[derive(Debug, Error)]
10pub enum ConfigValidationError {
11    /// Unknown module
12    #[error("Unknown module: {0}")]
13    UnknownModule(String),
14
15    /// Invalid configuration
16    #[error("Invalid configuration for {module}: {error}")]
17    InvalidConfig { module: String, error: String },
18
19    /// Required field missing
20    #[error("Required field missing: {field}")]
21    RequiredField { field: String },
22
23    /// Invalid value
24    #[error("Invalid value for field {field}: {value}")]
25    InvalidValue { field: String, value: String },
26
27    /// Multiple errors
28    #[error("Configuration has {0} errors")]
29    MultipleErrors(usize),
30}
31
32/// Validate the entire configuration
33///
34/// Returns detailed errors for any invalid configuration.
35pub fn validate_config(config: &BackboneConfig) -> ConfigResult<()> {
36    let mut errors: Vec<String> = Vec::new();
37
38    // Validate server configuration
39    if let Err(e) = validate_server(&config.server) {
40        errors.push(e);
41    }
42
43    // Validate database configurations
44    for (name, db_config) in &config.database {
45        if let Err(e) = validate_database(name, db_config) {
46            errors.push(e);
47        }
48    }
49
50    // Validate at least one database exists
51    if config.database.is_empty() {
52        errors.push("At least one database configuration is required".to_string());
53    }
54
55    // Validate default database exists
56    if !config.database.contains_key("default") {
57        errors.push("Default database configuration is required".to_string());
58    }
59
60    // Validate modules
61    if let Err(e) = validate_sapiens_module(&config.modules.sapiens, config.app.environment) {
62        errors.push(e);
63    }
64
65    if let Err(e) = validate_postman_module(&config.modules.postman) {
66        errors.push(e);
67    }
68
69    if let Err(e) = validate_bucket_module(&config.modules.bucket) {
70        errors.push(e);
71    }
72
73    // Validate logging
74    if let Err(e) = validate_logging(&config.logging) {
75        errors.push(e);
76    }
77
78    // Validate security
79    if let Err(e) = validate_security(&config.security) {
80        errors.push(e);
81    }
82
83    // Return errors if any
84    if !errors.is_empty() {
85        let error_msg = errors.join("; ");
86        return Err(ConfigError::validation(error_msg));
87    }
88
89    Ok(())
90}
91
92/// Validate server configuration
93fn validate_server(config: &super::ServerConfig) -> Result<(), String> {
94    if config.port == 0 {
95        return Err("Server port must be greater than 0".to_string());
96    }
97
98    // Note: u16 max is 65535, so this check is implicit in the type
99
100    if config.host.is_empty() {
101        return Err("Server host cannot be empty".to_string());
102    }
103
104    if let Some(workers) = config.workers {
105        if workers == 0 {
106            return Err("Server workers must be greater than 0".to_string());
107        }
108        if workers > 1024 {
109            return Err(format!("Server workers {} is too high (max 1024)", workers));
110        }
111    }
112
113    Ok(())
114}
115
116/// Validate database configuration
117fn validate_database(name: &str, config: &super::DatabaseConfig) -> Result<(), String> {
118    if config.url.is_empty() {
119        return Err(format!("Database '{}' URL cannot be empty", name));
120    }
121
122    // Basic URL format check
123    if !config.url.starts_with("postgresql://") && !config.url.starts_with("postgres://") {
124        return Err(format!(
125            "Database '{}' URL must start with postgresql:// or postgres://",
126            name
127        ));
128    }
129
130    if config.max_connections == 0 {
131        return Err(format!("Database '{}' max_connections must be > 0", name));
132    }
133
134    if config.min_connections > config.max_connections {
135        return Err(format!(
136            "Database '{}' min_connections ({}) cannot exceed max_connections ({})",
137            name, config.min_connections, config.max_connections
138        ));
139    }
140
141    Ok(())
142}
143
144/// Validate Sapiens module configuration
145fn validate_sapiens_module(config: &super::SapiensConfig, env: super::Environment) -> Result<(), String> {
146    if !config.enabled {
147        return Ok(());
148    }
149
150    // Validate auth configuration if present
151    if let Some(ref auth) = config.auth {
152        // JWT secret required in production
153        #[allow(clippy::collapsible_if)]
154        if auth.jwt_secret.is_empty() {
155            if matches!(env, super::Environment::Production) {
156                return Err(
157                    "Sapiens: JWT secret is required in production. Set JWT_SECRET environment variable."
158                        .to_string(),
159                );
160            }
161        }
162
163        // Validate password hasher
164        let hasher = &auth.password_hasher;
165        if hasher.iterations < 1000 || hasher.iterations > 1_000_000 {
166            return Err(format!(
167                "Sapiens: password_hasher.iterations ({}) must be between 1000 and 1000000",
168                hasher.iterations
169            ));
170        }
171
172        if hasher.memory == 0 || hasher.memory > 1024 {
173            return Err(format!(
174                "Sapiens: password_hasher.memory ({}) must be between 1 and 1024",
175                hasher.memory
176            ));
177        }
178
179        if hasher.parallelism == 0 || hasher.parallelism > 128 {
180            return Err(format!(
181                "Sapiens: password_hasher.parallelism ({}) must be between 1 and 128",
182                hasher.parallelism
183            ));
184        }
185
186        if hasher.hash_length < 16 || hasher.hash_length > 128 {
187            return Err(format!(
188                "Sapiens: password_hasher.hash_length ({}) must be between 16 and 128",
189                hasher.hash_length
190            ));
191        }
192
193        // Validate token expiration
194        if auth.token_expiration_hours == 0 {
195            return Err("Sapiens: token_expiration_hours must be > 0".to_string());
196        }
197
198        if auth.refresh_token_expiration_days == 0 {
199            return Err("Sapiens: refresh_token_expiration_days must be > 0".to_string());
200        }
201    }
202
203    // Validate lockout configuration if present
204    if let Some(ref lockout) = config.lockout {
205        if lockout.max_attempts == 0 {
206            return Err("Sapiens: lockout.max_attempts must be > 0".to_string());
207        }
208
209        if lockout.duration_minutes == 0 {
210            return Err("Sapiens: lockout.duration_minutes must be > 0".to_string());
211        }
212    }
213
214    Ok(())
215}
216
217/// Validate Postman module configuration
218fn validate_postman_module(config: &super::PostmanConfig) -> Result<(), String> {
219    if !config.enabled {
220        return Ok(());
221    }
222
223    // SMTP configuration required when enabled
224    match &config.smtp {
225        None => {
226            return Err("Postman: SMTP configuration is required when module is enabled".to_string());
227        }
228        Some(smtp) => {
229            if smtp.host.is_empty() {
230                return Err("Postman: smtp.host is required".to_string());
231            }
232
233            if smtp.port == 0 {
234                return Err(format!("Postman: smtp.port ({}) is invalid", smtp.port));
235            }
236
237            if smtp.from_email.is_empty() {
238                return Err("Postman: smtp.from_email is required".to_string());
239            }
240
241            // Basic email format check
242            if !smtp.from_email.contains('@') {
243                return Err(format!(
244                    "Postman: smtp.from_email '{}' is not a valid email",
245                    smtp.from_email
246                ));
247            }
248        }
249    }
250
251    Ok(())
252}
253
254/// Validate Bucket module configuration
255fn validate_bucket_module(config: &super::BucketConfig) -> Result<(), String> {
256    if !config.enabled {
257        return Ok(());
258    }
259
260    // Storage configuration required when enabled
261    match &config.storage {
262        None => {
263            return Err("Bucket: storage configuration is required when module is enabled".to_string());
264        }
265        Some(storage) => {
266            let valid_drivers = ["local", "s3", "minio"];
267            if !valid_drivers.contains(&storage.driver.as_str()) {
268                return Err(format!(
269                    "Bucket: storage.driver '{}' is invalid. Must be one of: {:?}",
270                    storage.driver, valid_drivers
271                ));
272            }
273
274            // Local storage requires base_path
275            #[allow(clippy::collapsible_if)]
276            if storage.driver == "local" {
277                if storage.base_path.as_ref().is_none_or(|p| p.is_empty()) {
278                    return Err(
279                        "Bucket: storage.base_path is required for local driver".to_string()
280                    );
281                }
282            }
283        }
284    }
285
286    Ok(())
287}
288
289/// Validate logging configuration
290fn validate_logging(config: &super::LoggingConfig) -> Result<(), String> {
291    let valid_levels = ["trace", "debug", "info", "warn", "error"];
292    if !valid_levels.contains(&config.level.to_lowercase().as_str()) {
293        return Err(format!(
294            "Logging: level '{}' is invalid. Must be one of: {:?}",
295            config.level, valid_levels
296        ));
297    }
298
299    let valid_formats = ["json", "pretty", "compact"];
300    if !valid_formats.contains(&config.format.to_lowercase().as_str()) {
301        return Err(format!(
302            "Logging: format '{}' is invalid. Must be one of: {:?}",
303            config.format, valid_formats
304        ));
305    }
306
307    if config.targets.is_empty() {
308        return Err("Logging: at least one target is required".to_string());
309    }
310
311    let valid_targets = ["console", "file", "stdout", "stderr"];
312    for target in &config.targets {
313        if !valid_targets.contains(&target.to_lowercase().as_str()) {
314            return Err(format!(
315                "Logging: target '{}' is invalid. Must be one of: {:?}",
316                target, valid_targets
317            ));
318        }
319    }
320
321    // File config validation
322    #[allow(clippy::collapsible_if)]
323    if config.targets.contains(&"file".to_string()) {
324        if config.file.is_none() {
325            return Err("Logging: file configuration is required when 'file' target is enabled".to_string());
326        }
327    }
328
329    Ok(())
330}
331
332/// Validate security configuration
333fn validate_security(config: &super::SecurityConfig) -> Result<(), String> {
334    if config.cors_enabled {
335        if config.cors_origins.is_empty() {
336            return Err("Security: cors_origins cannot be empty when CORS is enabled".to_string());
337        }
338
339        if config.cors_methods.is_empty() {
340            return Err("Security: cors_methods cannot be empty when CORS is enabled".to_string());
341        }
342
343        // Validate HTTP methods
344        let valid_methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"];
345        for method in &config.cors_methods {
346            if !valid_methods.contains(&method.to_uppercase().as_str()) {
347                return Err(format!(
348                    "Security: cors_method '{}' is invalid. Must be one of: {:?}",
349                    method, valid_methods
350                ));
351            }
352        }
353    }
354
355    // CSRF validation
356    if let Some(ref csrf) = config.csrf {
357        if csrf.enabled {
358            if csrf.token_length < 16 || csrf.token_length > 128 {
359                return Err(format!(
360                    "Security: csrf.token_length ({}) must be between 16 and 128",
361                    csrf.token_length
362                ));
363            }
364
365            if csrf.expires_in == 0 {
366                return Err("Security: csrf.expires_in must be > 0".to_string());
367            }
368        }
369    }
370
371    Ok(())
372}
373
374/// Validate a specific module by name
375#[allow(dead_code)]
376pub fn validate_module(module_name: &str, config: &BackboneConfig) -> Result<(), ConfigValidationError> {
377    match module_name {
378        "sapiens" => {
379            validate_sapiens_module(&config.modules.sapiens, config.app.environment)
380                .map_err(|e| ConfigValidationError::InvalidConfig {
381                    module: "sapiens".to_string(),
382                    error: e,
383                })
384        }
385        "postman" => {
386            validate_postman_module(&config.modules.postman)
387                .map_err(|e| ConfigValidationError::InvalidConfig {
388                    module: "postman".to_string(),
389                    error: e,
390                })
391        }
392        "bucket" => {
393            validate_bucket_module(&config.modules.bucket)
394                .map_err(|e| ConfigValidationError::InvalidConfig {
395                    module: "bucket".to_string(),
396                    error: e,
397                })
398        }
399        _ => Err(ConfigValidationError::UnknownModule(module_name.to_string())),
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::config::*;
407
408    #[test]
409    fn test_validate_server_valid() {
410        let config = ServerConfig::default();
411        assert!(validate_server(&config).is_ok());
412    }
413
414    #[test]
415    fn test_validate_server_invalid_port() {
416        let mut config = ServerConfig::default();
417        config.port = 0;
418        assert!(validate_server(&config).is_err());
419    }
420
421    #[test]
422    fn test_validate_database_valid() {
423        let config = DatabaseConfig::default();
424        assert!(validate_database("default", &config).is_ok());
425    }
426
427    #[test]
428    fn test_validate_database_invalid_url() {
429        let mut config = DatabaseConfig::default();
430        config.url = "mysql://localhost".to_string();
431        assert!(validate_database("default", &config).is_err());
432    }
433
434    #[test]
435    fn test_validate_sapiens_disabled() {
436        let mut config = SapiensConfig::default();
437        config.enabled = false;
438        assert!(validate_sapiens_module(&config, Environment::Development).is_ok());
439    }
440
441    #[test]
442    fn test_validate_sapiens_invalid_hasher() {
443        let mut config = SapiensConfig::default();
444        config.enabled = true;
445        if let Some(ref mut auth) = config.auth {
446            auth.password_hasher.iterations = 100; // Too low
447        }
448        assert!(validate_sapiens_module(&config, Environment::Development).is_err());
449    }
450
451    #[test]
452    fn test_validate_postman_disabled() {
453        let config = PostmanConfig::default();
454        assert!(validate_postman_module(&config).is_ok());
455    }
456
457    #[test]
458    fn test_validate_postman_enabled_no_smtp() {
459        let mut config = PostmanConfig::default();
460        config.enabled = true;
461        config.smtp = None;
462        assert!(validate_postman_module(&config).is_err());
463    }
464
465    #[test]
466    fn test_validate_logging_valid() {
467        let config = LoggingConfig::default();
468        assert!(validate_logging(&config).is_ok());
469    }
470
471    #[test]
472    fn test_validate_logging_invalid_level() {
473        let mut config = LoggingConfig::default();
474        config.level = "invalid".to_string();
475        assert!(validate_logging(&config).is_err());
476    }
477
478    #[test]
479    fn test_validate_security_valid() {
480        let config = SecurityConfig::default();
481        assert!(validate_security(&config).is_ok());
482    }
483
484    #[test]
485    fn test_validate_security_empty_origins() {
486        let mut config = SecurityConfig::default();
487        config.cors_enabled = true;
488        config.cors_origins = vec![];
489        assert!(validate_security(&config).is_err());
490    }
491
492    #[test]
493    fn test_validate_full_config() {
494        let config = BackboneConfig::default();
495        assert!(validate_config(&config).is_ok());
496    }
497}