backbone_core/config/
schema.rs1use super::{BackboneConfig, ConfigError, ConfigResult};
6use thiserror::Error;
7
8#[derive(Debug, Error)]
10pub enum ConfigValidationError {
11 #[error("Unknown module: {0}")]
13 UnknownModule(String),
14
15 #[error("Invalid configuration for {module}: {error}")]
17 InvalidConfig { module: String, error: String },
18
19 #[error("Required field missing: {field}")]
21 RequiredField { field: String },
22
23 #[error("Invalid value for field {field}: {value}")]
25 InvalidValue { field: String, value: String },
26
27 #[error("Configuration has {0} errors")]
29 MultipleErrors(usize),
30}
31
32pub fn validate_config(config: &BackboneConfig) -> ConfigResult<()> {
36 let mut errors: Vec<String> = Vec::new();
37
38 if let Err(e) = validate_server(&config.server) {
40 errors.push(e);
41 }
42
43 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 if config.database.is_empty() {
52 errors.push("At least one database configuration is required".to_string());
53 }
54
55 if !config.database.contains_key("default") {
57 errors.push("Default database configuration is required".to_string());
58 }
59
60 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 if let Err(e) = validate_logging(&config.logging) {
75 errors.push(e);
76 }
77
78 if let Err(e) = validate_security(&config.security) {
80 errors.push(e);
81 }
82
83 if !errors.is_empty() {
85 let error_msg = errors.join("; ");
86 return Err(ConfigError::validation(error_msg));
87 }
88
89 Ok(())
90}
91
92fn 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 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
116fn 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 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
144fn validate_sapiens_module(config: &super::SapiensConfig, env: super::Environment) -> Result<(), String> {
146 if !config.enabled {
147 return Ok(());
148 }
149
150 if let Some(ref auth) = config.auth {
152 #[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 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 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 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
217fn validate_postman_module(config: &super::PostmanConfig) -> Result<(), String> {
219 if !config.enabled {
220 return Ok(());
221 }
222
223 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 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
254fn validate_bucket_module(config: &super::BucketConfig) -> Result<(), String> {
256 if !config.enabled {
257 return Ok(());
258 }
259
260 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 #[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
289fn 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 #[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
332fn 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 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 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#[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; }
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}