Skip to main content

allsource_core/infrastructure/config/
config.rs

1use crate::error::{AllSourceError, Result};
2/// Configuration management for AllSource v1.0
3///
4/// Features:
5/// - Environment-based configuration
6/// - TOML file support
7/// - Runtime configuration validation
8/// - Hot-reloading support (via file watcher)
9/// - Secure credential handling
10use serde::{Deserialize, Serialize};
11use std::{
12    fs,
13    path::{Component, Path, PathBuf},
14};
15
16/// Reject obviously-unsafe config paths before opening them.
17fn validate_config_path(path: &Path) -> Result<()> {
18    let os = path.as_os_str();
19    if os.is_empty() {
20        return Err(AllSourceError::ValidationError(
21            "config path must not be empty".to_string(),
22        ));
23    }
24    let bytes = os.as_encoded_bytes();
25    if bytes.contains(&0) {
26        return Err(AllSourceError::ValidationError(
27            "config path contains a null byte".to_string(),
28        ));
29    }
30    if path.components().any(|c| matches!(c, Component::ParentDir)) {
31        return Err(AllSourceError::ValidationError(
32            "config path must not contain '..' components".to_string(),
33        ));
34    }
35    Ok(())
36}
37
38/// Main application configuration
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct Config {
41    pub server: ServerConfig,
42    pub storage: StorageConfig,
43    pub auth: AuthConfig,
44    pub rate_limit: RateLimitConfigFile,
45    pub backup: BackupConfigFile,
46    pub metrics: MetricsConfig,
47    pub logging: LoggingConfig,
48}
49
50/// Server configuration
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ServerConfig {
53    pub host: String,
54    pub port: u16,
55    pub workers: Option<usize>,
56    pub max_connections: usize,
57    pub request_timeout_secs: u64,
58    pub cors_enabled: bool,
59    pub cors_origins: Vec<String>,
60}
61
62impl Default for ServerConfig {
63    fn default() -> Self {
64        Self {
65            host: "0.0.0.0".to_string(),
66            port: 3900,
67            workers: None, // Use number of CPUs
68            max_connections: 10_000,
69            request_timeout_secs: 30,
70            cors_enabled: true,
71            cors_origins: vec!["*".to_string()],
72        }
73    }
74}
75
76impl ServerConfig {
77    /// Create ServerConfig from environment variables.
78    /// Reads ALLSOURCE_HOST (or HOST), ALLSOURCE_PORT (or PORT), falling back to defaults.
79    pub fn from_env() -> Self {
80        let mut config = Self::default();
81
82        if let Ok(host) = std::env::var("ALLSOURCE_HOST").or_else(|_| std::env::var("HOST")) {
83            config.host = host;
84        }
85
86        if let Ok(port) = std::env::var("ALLSOURCE_PORT").or_else(|_| std::env::var("PORT"))
87            && let Ok(p) = port.parse::<u16>()
88        {
89            config.port = p;
90        }
91
92        config
93    }
94}
95
96/// Storage configuration
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct StorageConfig {
99    pub data_dir: PathBuf,
100    pub wal_dir: PathBuf,
101    pub batch_size: usize,
102    pub compression: CompressionType,
103    pub retention_days: Option<u32>,
104    pub max_storage_gb: Option<u32>,
105}
106
107impl Default for StorageConfig {
108    fn default() -> Self {
109        Self {
110            data_dir: PathBuf::from("./data"),
111            wal_dir: PathBuf::from("./wal"),
112            batch_size: 1000,
113            compression: CompressionType::Lz4,
114            retention_days: None,
115            max_storage_gb: None,
116        }
117    }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121#[serde(rename_all = "lowercase")]
122pub enum CompressionType {
123    None,
124    Lz4,
125    Gzip,
126    Snappy,
127}
128
129/// Authentication configuration
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct AuthConfig {
132    pub jwt_secret: String,
133    pub jwt_expiry_hours: i64,
134    pub api_key_expiry_days: Option<i64>,
135    pub password_min_length: usize,
136    pub require_email_verification: bool,
137    pub session_timeout_minutes: u64,
138}
139
140impl Default for AuthConfig {
141    fn default() -> Self {
142        Self {
143            jwt_secret: "CHANGE_ME_IN_PRODUCTION".to_string(),
144            jwt_expiry_hours: 24,
145            api_key_expiry_days: Some(90),
146            password_min_length: 8,
147            require_email_verification: false,
148            session_timeout_minutes: 60,
149        }
150    }
151}
152
153/// Rate limiting configuration
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct RateLimitConfigFile {
156    pub enabled: bool,
157    pub default_tier: RateLimitTier,
158    pub requests_per_minute: Option<u32>,
159    pub burst_size: Option<u32>,
160}
161
162impl Default for RateLimitConfigFile {
163    fn default() -> Self {
164        Self {
165            enabled: true,
166            default_tier: RateLimitTier::Professional,
167            requests_per_minute: None,
168            burst_size: None,
169        }
170    }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174#[serde(rename_all = "lowercase")]
175pub enum RateLimitTier {
176    Free,
177    Professional,
178    Unlimited,
179    Custom,
180}
181
182/// Backup configuration
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct BackupConfigFile {
185    pub enabled: bool,
186    pub backup_dir: PathBuf,
187    pub schedule_cron: Option<String>,
188    pub retention_count: usize,
189    pub compression_level: u8,
190    pub verify_after_backup: bool,
191}
192
193impl Default for BackupConfigFile {
194    fn default() -> Self {
195        Self {
196            enabled: false,
197            backup_dir: PathBuf::from("./backups"),
198            schedule_cron: None, // e.g., "0 2 * * *" for 2am daily
199            retention_count: 7,
200            compression_level: 6,
201            verify_after_backup: true,
202        }
203    }
204}
205
206/// Metrics configuration
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct MetricsConfig {
209    pub enabled: bool,
210    pub endpoint: String,
211    pub push_interval_secs: Option<u64>,
212    pub push_gateway_url: Option<String>,
213}
214
215impl Default for MetricsConfig {
216    fn default() -> Self {
217        Self {
218            enabled: true,
219            endpoint: "/metrics".to_string(),
220            push_interval_secs: None,
221            push_gateway_url: None,
222        }
223    }
224}
225
226/// Logging configuration
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct LoggingConfig {
229    pub level: LogLevel,
230    pub format: LogFormat,
231    pub output: LogOutput,
232    pub file_path: Option<PathBuf>,
233    pub rotate_size_mb: Option<u64>,
234}
235
236impl Default for LoggingConfig {
237    fn default() -> Self {
238        Self {
239            level: LogLevel::Info,
240            format: LogFormat::Pretty,
241            output: LogOutput::Stdout,
242            file_path: None,
243            rotate_size_mb: Some(100),
244        }
245    }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
249#[serde(rename_all = "lowercase")]
250pub enum LogLevel {
251    Trace,
252    Debug,
253    Info,
254    Warn,
255    Error,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
259#[serde(rename_all = "lowercase")]
260pub enum LogFormat {
261    Json,
262    Pretty,
263    Compact,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267#[serde(rename_all = "lowercase")]
268pub enum LogOutput {
269    Stdout,
270    Stderr,
271    File,
272    Both,
273}
274
275impl Config {
276    /// Load configuration from file.
277    ///
278    /// The path is supplied by the operator (CLI flag or env var), not by an
279    /// HTTP client. We still reject obviously malformed paths (embedded null
280    /// bytes, parent-directory traversal components) so that a mis-configured
281    /// deployment fails fast instead of silently reading an unintended file.
282    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
283        let path_ref = path.as_ref();
284        validate_config_path(path_ref)?;
285        let content = fs::read_to_string(path_ref).map_err(|e| {
286            AllSourceError::StorageError(format!("Failed to read config file: {e}"))
287        })?;
288
289        toml::from_str(&content)
290            .map_err(|e| AllSourceError::ValidationError(format!("Invalid config format: {e}")))
291    }
292
293    /// Load configuration from environment variables
294    /// Variables are prefixed with ALLSOURCE_
295    /// Also supports standard PORT env var for serverless platforms (Cloud Run, Fly.io, etc.)
296    pub fn from_env() -> Result<Self> {
297        let mut config = Config::default();
298
299        // Server - check ALLSOURCE_HOST first, then HOST (serverless fallback)
300        if let Ok(host) = std::env::var("ALLSOURCE_HOST").or_else(|_| std::env::var("HOST")) {
301            config.server.host = host;
302        }
303
304        // Port priority: ALLSOURCE_PORT > PORT (serverless standard)
305        let port_str = std::env::var("ALLSOURCE_PORT").or_else(|_| std::env::var("PORT"));
306        if let Ok(port) = port_str {
307            config.server.port = port
308                .parse()
309                .map_err(|_| AllSourceError::ValidationError("Invalid port number".to_string()))?;
310        }
311
312        // Storage
313        if let Ok(data_dir) = std::env::var("ALLSOURCE_DATA_DIR") {
314            config.storage.data_dir = PathBuf::from(data_dir);
315        }
316
317        // Auth
318        if let Ok(jwt_secret) = std::env::var("ALLSOURCE_JWT_SECRET") {
319            config.auth.jwt_secret = jwt_secret;
320        }
321
322        Ok(config)
323    }
324
325    /// Load configuration with fallback priority:
326    /// 1. Config file (if provided)
327    /// 2. Environment variables
328    /// 3. Defaults
329    pub fn load(config_path: Option<PathBuf>) -> Result<Self> {
330        let mut config = if let Some(path) = config_path {
331            if path.exists() {
332                tracing::info!("Loading config from: {}", path.display());
333                Self::from_file(path)?
334            } else {
335                tracing::warn!("Config file not found: {}, using defaults", path.display());
336                Config::default()
337            }
338        } else {
339            Config::default()
340        };
341
342        // Override with environment variables
343        if let Ok(env_config) = Self::from_env() {
344            config.merge_env(env_config);
345        }
346
347        config.validate()?;
348
349        Ok(config)
350    }
351
352    /// Merge environment variable overrides
353    fn merge_env(&mut self, env_config: Config) {
354        // Merge server config
355        if env_config.server.host != ServerConfig::default().host {
356            self.server.host = env_config.server.host;
357        }
358        if env_config.server.port != ServerConfig::default().port {
359            self.server.port = env_config.server.port;
360        }
361
362        // Merge storage config
363        if env_config.storage.data_dir != StorageConfig::default().data_dir {
364            self.storage.data_dir = env_config.storage.data_dir;
365        }
366
367        // Merge auth config
368        if env_config.auth.jwt_secret != AuthConfig::default().jwt_secret {
369            self.auth.jwt_secret = env_config.auth.jwt_secret;
370        }
371    }
372
373    /// Validate configuration
374    pub fn validate(&self) -> Result<()> {
375        // Validate port
376        if self.server.port == 0 {
377            return Err(AllSourceError::ValidationError(
378                "Server port cannot be 0".to_string(),
379            ));
380        }
381
382        // Validate JWT secret in production
383        if self.auth.jwt_secret == "CHANGE_ME_IN_PRODUCTION" {
384            tracing::warn!("⚠️  Using default JWT secret - INSECURE for production!");
385        }
386
387        // Validate storage paths
388        if self.storage.data_dir.as_os_str().is_empty() {
389            return Err(AllSourceError::ValidationError(
390                "Data directory path cannot be empty".to_string(),
391            ));
392        }
393
394        Ok(())
395    }
396
397    /// Save configuration to TOML file
398    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
399        let toml = toml::to_string_pretty(self).map_err(|e| {
400            AllSourceError::ValidationError(format!("Failed to serialize config: {e}"))
401        })?;
402
403        fs::write(path.as_ref(), toml).map_err(|e| {
404            AllSourceError::StorageError(format!("Failed to write config file: {e}"))
405        })?;
406
407        Ok(())
408    }
409
410    /// Generate example configuration file
411    pub fn example() -> String {
412        toml::to_string_pretty(&Config::default())
413            .unwrap_or_else(|_| String::from("# Failed to generate example config"))
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_default_config() {
423        let config = Config::default();
424        assert_eq!(config.server.port, 3900);
425        assert!(config.rate_limit.enabled);
426    }
427
428    #[test]
429    fn test_config_validation() {
430        let config = Config::default();
431        assert!(config.validate().is_ok());
432    }
433
434    #[test]
435    fn test_invalid_port() {
436        let mut config = Config::default();
437        config.server.port = 0;
438        assert!(config.validate().is_err());
439    }
440
441    #[test]
442    fn test_validate_config_path_accepts_normal_paths() {
443        assert!(validate_config_path(Path::new("config.toml")).is_ok());
444        assert!(validate_config_path(Path::new("/etc/allsource/config.toml")).is_ok());
445        assert!(validate_config_path(Path::new("./config/allsource.toml")).is_ok());
446    }
447
448    #[test]
449    fn test_validate_config_path_rejects_traversal_and_nulls() {
450        assert!(validate_config_path(Path::new("")).is_err());
451        assert!(validate_config_path(Path::new("../secret.toml")).is_err());
452        assert!(validate_config_path(Path::new("config/../../secret.toml")).is_err());
453    }
454
455    #[test]
456    fn test_config_serialization() {
457        let config = Config::default();
458        let toml = toml::to_string(&config).unwrap();
459        let deserialized: Config = toml::from_str(&toml).unwrap();
460        assert_eq!(config.server.port, deserialized.server.port);
461    }
462}