1use crate::error::{AllSourceError, Result};
2use serde::{Deserialize, Serialize};
11use std::{
12 fs,
13 path::{Component, Path, PathBuf},
14};
15
16fn 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#[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#[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, 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 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#[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#[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#[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#[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, retention_count: 7,
200 compression_level: 6,
201 verify_after_backup: true,
202 }
203 }
204}
205
206#[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#[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 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 pub fn from_env() -> Result<Self> {
297 let mut config = Config::default();
298
299 if let Ok(host) = std::env::var("ALLSOURCE_HOST").or_else(|_| std::env::var("HOST")) {
301 config.server.host = host;
302 }
303
304 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 if let Ok(data_dir) = std::env::var("ALLSOURCE_DATA_DIR") {
314 config.storage.data_dir = PathBuf::from(data_dir);
315 }
316
317 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 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 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 fn merge_env(&mut self, env_config: Config) {
354 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 if env_config.storage.data_dir != StorageConfig::default().data_dir {
364 self.storage.data_dir = env_config.storage.data_dir;
365 }
366
367 if env_config.auth.jwt_secret != AuthConfig::default().jwt_secret {
369 self.auth.jwt_secret = env_config.auth.jwt_secret;
370 }
371 }
372
373 pub fn validate(&self) -> Result<()> {
375 if self.server.port == 0 {
377 return Err(AllSourceError::ValidationError(
378 "Server port cannot be 0".to_string(),
379 ));
380 }
381
382 if self.auth.jwt_secret == "CHANGE_ME_IN_PRODUCTION" {
384 tracing::warn!("⚠️ Using default JWT secret - INSECURE for production!");
385 }
386
387 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 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 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}