use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct S3Config { #[serde(default)] pub s3: S3Section, #[serde(default)] pub keys: KeysSection, #[serde(default)] pub buckets: BucketsSection, #[serde(default)] pub permissions: PermissionsSection, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct S3Section { #[serde(default = "default_enabled")] pub enabled: bool, #[serde(default = "default_endpoint")] pub endpoint: String, #[serde(default = "default_region")] pub region: String, #[serde(default = "default_service")] pub service: String, #[serde(default = "default_require_auth")] pub require_auth: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KeysSection { #[serde(default = "default_access_key")] pub default_access_key: String, #[serde(default = "default_secret_key")] pub default_secret_key: String, #[serde(default = "default_keys_db_path")] pub keys_db_path: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BucketsSection { #[serde(default)] pub mappings: std::collections::HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PermissionsSection { #[serde(default = "default_permissions")] pub default_permissions: Vec, #[serde(default = "admin_permissions")] pub admin_permissions: Vec, } fn default_enabled() -> bool { true } fn default_endpoint() -> String { "http://localhost:11438/s3".to_string() } fn default_region() -> String { "us-east-1".to_string() } fn default_service() -> String { "s3".to_string() } fn default_require_auth() -> bool { false } fn default_access_key() -> String { "markbase_access_key_001".to_string() } fn default_secret_key() -> String { "markbase_secret_key_xyz123".to_string() } fn default_keys_db_path() -> String { "data/s3_keys.json".to_string() } fn default_permissions() -> Vec { vec![ "GetObject".to_string(), "ListBucket".to_string(), "HeadObject".to_string(), ] } fn admin_permissions() -> Vec { vec![ "GetObject".to_string(), "PutObject".to_string(), "DeleteObject".to_string(), "ListBucket".to_string(), "HeadObject".to_string(), ] } impl Default for S3Config { fn default() -> Self { Self { s3: S3Section::default(), keys: KeysSection::default(), buckets: BucketsSection::default(), permissions: PermissionsSection::default(), } } } impl Default for S3Section { fn default() -> Self { Self { enabled: default_enabled(), endpoint: default_endpoint(), region: default_region(), service: default_service(), require_auth: default_require_auth(), } } } impl Default for KeysSection { fn default() -> Self { Self { default_access_key: default_access_key(), default_secret_key: default_secret_key(), keys_db_path: default_keys_db_path(), } } } impl Default for BucketsSection { fn default() -> Self { Self { mappings: std::collections::HashMap::new(), } } } impl Default for PermissionsSection { fn default() -> Self { Self { default_permissions: default_permissions(), admin_permissions: admin_permissions(), } } } impl S3Config { pub fn load(path: &str) -> Result { let config_path = PathBuf::from(path); if !config_path.exists() { log::warn!("S3 config file not found: {}, using defaults", path); return Ok(Self::default()); } let content = fs::read_to_string(&config_path) .with_context(|| format!("Failed to read S3 config: {}", path))?; let config: S3Config = toml::from_str(&content) .with_context(|| format!("Failed to parse S3 config: {}", path))?; log::info!("S3 config loaded from: {}", path); Ok(config) } pub fn load_default() -> Result { Self::load("config/s3.toml") } pub fn save(&self, path: &str) -> Result<()> { let config_path = PathBuf::from(path); // Create backup before saving if config_path.exists() { let backup_path = config_path.with_extension("toml.bak"); std::fs::copy(&config_path, &backup_path) .with_context(|| format!("Failed to create backup: {}", backup_path.display()))?; log::info!("S3 config backup created: {}", backup_path.display()); } let content = toml::to_string_pretty(self) .with_context(|| "Failed to serialize S3 config")?; std::fs::write(&config_path, content) .with_context(|| format!("Failed to write S3 config: {}", path))?; log::info!("S3 config saved to: {}", path); Ok(()) } pub fn merge_env(&mut self) { if let Ok(require_auth) = std::env::var("MB_S3_REQUIRE_AUTH") { self.s3.require_auth = require_auth == "true" || require_auth == "1"; } if let Ok(endpoint) = std::env::var("MB_S3_ENDPOINT") { self.s3.endpoint = endpoint; } if let Ok(region) = std::env::var("MB_S3_REGION") { self.s3.region = region; } if let Ok(access_key) = std::env::var("MB_S3_ACCESS_KEY") { self.keys.default_access_key = access_key; } if let Ok(secret_key) = std::env::var("MB_S3_SECRET_KEY") { self.keys.default_secret_key = secret_key; } } pub fn validate(&self) -> Result<()> { if self.s3.endpoint.is_empty() { return Err(anyhow::anyhow!("S3 endpoint cannot be empty")); } // Validate endpoint format (should start with http:// or https://) if !self.s3.endpoint.starts_with("http://") && !self.s3.endpoint.starts_with("https://") { return Err(anyhow::anyhow!( "S3 endpoint must start with http:// or https://. Current: {}", self.s3.endpoint )); } if self.s3.region.is_empty() { return Err(anyhow::anyhow!("S3 region cannot be empty")); } if self.s3.service.is_empty() { return Err(anyhow::anyhow!("S3 service cannot be empty")); } if self.keys.default_access_key.is_empty() { return Err(anyhow::anyhow!("S3 access key cannot be empty")); } if self.keys.default_secret_key.is_empty() { return Err(anyhow::anyhow!("S3 secret key cannot be empty")); } if self.keys.keys_db_path.is_empty() { return Err(anyhow::anyhow!("S3 keys_db_path cannot be empty")); } if self.permissions.default_permissions.is_empty() { return Err(anyhow::anyhow!("default_permissions cannot be empty")); } if self.permissions.admin_permissions.is_empty() { return Err(anyhow::anyhow!("admin_permissions cannot be empty")); } // Validate permission format let valid_permissions = [ "GetObject", "PutObject", "DeleteObject", "ListBucket", "HeadObject", "ListAllMyBuckets", "CreateBucket", "DeleteBucket" ]; for perm in &self.permissions.default_permissions { if !valid_permissions.contains(&perm.as_str()) { return Err(anyhow::anyhow!( "Invalid permission: {}. Must be one of: {}", perm, valid_permissions.join(", ") )); } } for perm in &self.permissions.admin_permissions { if !valid_permissions.contains(&perm.as_str()) { return Err(anyhow::anyhow!( "Invalid admin permission: {}. Must be one of: {}", perm, valid_permissions.join(", ") )); } } Ok(()) } pub fn get(&self, key: &str) -> Option { match key { "s3.enabled" => Some(self.s3.enabled.to_string()), "s3.endpoint" => Some(self.s3.endpoint.clone()), "s3.region" => Some(self.s3.region.clone()), "s3.service" => Some(self.s3.service.clone()), "s3.require_auth" => Some(self.s3.require_auth.to_string()), "keys.default_access_key" => Some(self.keys.default_access_key.clone()), "keys.default_secret_key" => Some(self.keys.default_secret_key.clone()), "keys.keys_db_path" => Some(self.keys.keys_db_path.clone()), "permissions.default_permissions" => { Some(serde_json::to_string(&self.permissions.default_permissions).unwrap_or_default()) } "permissions.admin_permissions" => { Some(serde_json::to_string(&self.permissions.admin_permissions).unwrap_or_default()) } _ => None, } } pub fn set(&mut self, key: &str, value: &str) -> Result<()> { match key { "s3.enabled" => self.s3.enabled = value.parse()?, "s3.endpoint" => self.s3.endpoint = value.to_string(), "s3.region" => self.s3.region = value.to_string(), "s3.service" => self.s3.service = value.to_string(), "s3.require_auth" => self.s3.require_auth = value.parse()?, "keys.default_access_key" => self.keys.default_access_key = value.to_string(), "keys.default_secret_key" => self.keys.default_secret_key = value.to_string(), "keys.keys_db_path" => self.keys.keys_db_path = value.to_string(), "permissions.default_permissions" => { self.permissions.default_permissions = serde_json::from_str(value) .with_context(|| "Failed to parse permissions array")?; } "permissions.admin_permissions" => { self.permissions.admin_permissions = serde_json::from_str(value) .with_context(|| "Failed to parse admin permissions array")?; } _ => return Err(anyhow::anyhow!("Invalid S3 config key: {}", key)), } Ok(()) } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_default_config() { let config = S3Config::default(); assert_eq!(config.s3.enabled, true); assert_eq!(config.s3.require_auth, false); assert_eq!(config.s3.endpoint, "http://localhost:11438/s3"); assert_eq!(config.s3.region, "us-east-1"); assert_eq!(config.keys.default_access_key, "markbase_access_key_001"); assert_eq!(config.keys.default_secret_key, "markbase_secret_key_xyz123"); assert_eq!(config.permissions.default_permissions.len(), 3); assert_eq!(config.permissions.admin_permissions.len(), 5); } #[test] fn test_load_missing_config() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("missing.toml"); let config = S3Config::load(&config_path.to_string_lossy()).unwrap(); assert_eq!(config.s3.enabled, true); assert_eq!(config.s3.require_auth, false); } #[test] fn test_merge_env() { std::env::set_var("MB_S3_REQUIRE_AUTH", "true"); std::env::set_var("MB_S3_ENDPOINT", "http://custom.endpoint"); let mut config = S3Config::default(); config.merge_env(); assert_eq!(config.s3.require_auth, true); assert_eq!(config.s3.endpoint, "http://custom.endpoint"); std::env::remove_var("MB_S3_REQUIRE_AUTH"); std::env::remove_var("MB_S3_ENDPOINT"); } #[test] fn test_validate() { let config = S3Config::default(); assert!(config.validate().is_ok()); let mut invalid_config = S3Config::default(); invalid_config.s3.endpoint = "".to_string(); assert!(invalid_config.validate().is_err()); } #[test] fn test_get_set() { let mut config = S3Config::default(); assert_eq!(config.get("s3.enabled"), Some("true".to_string())); assert_eq!(config.get("s3.endpoint"), Some("http://localhost:11438/s3".to_string())); config.set("s3.require_auth", "true").unwrap(); assert_eq!(config.s3.require_auth, true); config.set("s3.endpoint", "http://new.endpoint").unwrap(); assert_eq!(config.s3.endpoint, "http://new.endpoint"); } }