diff --git a/Cargo.lock b/Cargo.lock index 87abc25..c87eafc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3985,11 +3985,14 @@ dependencies = [ "objects-transport", "serde", "serde_json", + "temp-env", + "tempfile", "thiserror 2.0.17", "tokio", "toml", "tracing", "tracing-subscriber", + "url", ] [[package]] @@ -6081,6 +6084,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "parking_lot", +] + [[package]] name = "tempfile" version = "3.24.0" diff --git a/Cargo.toml b/Cargo.toml index 7412a3c..e79cb82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,5 +83,9 @@ wiremock = "0.6" serde_test = "1" tempfile = "3" +# Config +toml = "0.8" +url = "2" + # Build prost-build = "0.14" diff --git a/bins/objects-node/Cargo.toml b/bins/objects-node/Cargo.toml index 30d2db8..727c122 100644 --- a/bins/objects-node/Cargo.toml +++ b/bins/objects-node/Cargo.toml @@ -19,7 +19,10 @@ futures.workspace = true # Serialization serde.workspace = true serde_json.workspace = true -toml = "0.8" +toml.workspace = true + +# Config +url.workspace = true # Error handling anyhow.workspace = true @@ -28,3 +31,7 @@ thiserror.workspace = true # Logging tracing.workspace = true tracing-subscriber.workspace = true + +[dev-dependencies] +tempfile.workspace = true +temp-env = "0.3" diff --git a/bins/objects-node/src/config.rs b/bins/objects-node/src/config.rs index 33afd42..7674fae 100644 --- a/bins/objects-node/src/config.rs +++ b/bins/objects-node/src/config.rs @@ -1,12 +1,40 @@ //! Configuration types for the OBJECTS node daemon. use serde::{Deserialize, Serialize}; +use std::net::IpAddr; +use std::path::Path; +use thiserror::Error; +use url::Url; + +/// Errors that can occur during configuration loading and validation. +#[derive(Debug, Error)] +pub enum ConfigError { + /// I/O error reading or writing configuration file. + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), + + /// Error parsing TOML configuration file. + #[error("Failed to parse config: {0}")] + ParseError(#[from] toml::de::Error), + + /// Configuration validation failed. + #[error("Invalid configuration: {0}")] + ValidationError(String), +} + +/// Result type for configuration operations. +pub type Result = std::result::Result; /// Main configuration for the OBJECTS node daemon. /// /// Configuration is loaded from a TOML file and can be overridden by environment variables. /// See individual field documentation for environment variable names. /// +/// Configuration precedence (highest to lowest): +/// 1. Environment variables +/// 2. Config file values +/// 3. Default values +/// /// # Example /// /// ``` @@ -28,6 +56,236 @@ pub struct NodeConfig { pub identity: IdentitySettings, } +impl NodeConfig { + /// Load configuration from environment variables only. + /// + /// Starts with default values and applies environment variable overrides. + pub fn from_env() -> Result { + tracing::debug!("Loading configuration from environment variables"); + let mut config = Self::default(); + config.apply_env_overrides(); + config.validate().map_err(|e| { + tracing::error!(error = %e, "Configuration validation failed"); + e + })?; + tracing::info!("Successfully loaded configuration from environment"); + Ok(config) + } + + /// Load configuration from a TOML file, creating it with defaults if it doesn't exist. + /// + /// If the file exists, it is loaded and environment variables are applied as overrides. + /// If the file doesn't exist, a default configuration is created and saved to the file. + pub fn load_or_create(path: &Path) -> Result { + tracing::debug!(path = %path.display(), "Loading or creating configuration"); + + if path.exists() { + tracing::debug!(path = %path.display(), "Configuration file exists, loading"); + let mut config = Self::load(path)?; + config.apply_env_overrides(); + config.validate()?; + Ok(config) + } else { + tracing::info!(path = %path.display(), "Configuration file doesn't exist, creating with defaults"); + let mut config = Self::default(); + config.apply_env_overrides(); + config.validate()?; + config.save(path)?; + tracing::info!(path = %path.display(), "Created configuration file"); + Ok(config) + } + } + + /// Load configuration from a TOML file. + /// + /// Returns an error if the file doesn't exist. + pub fn load(path: &Path) -> Result { + tracing::debug!(path = %path.display(), "Loading configuration from file"); + + let contents = std::fs::read_to_string(path).map_err(|e| { + tracing::error!(path = %path.display(), error = %e, "Failed to read configuration file"); + ConfigError::IoError(e) + })?; + + let config: Self = toml::from_str(&contents).map_err(|e| { + tracing::error!(path = %path.display(), error = %e, "Failed to parse TOML configuration"); + ConfigError::ParseError(e) + })?; + + tracing::debug!(path = %path.display(), "Successfully loaded configuration from file"); + Ok(config) + } + + /// Save configuration to a TOML file. + /// + /// Creates parent directories if they don't exist. + pub fn save(&self, path: &Path) -> Result<()> { + tracing::debug!(path = %path.display(), "Saving configuration to file"); + + if let Some(parent) = path.parent() { + tracing::debug!(parent = %parent.display(), "Creating parent directories"); + std::fs::create_dir_all(parent).map_err(|e| { + tracing::error!(parent = %parent.display(), error = %e, "Failed to create parent directories"); + ConfigError::IoError(e) + })?; + } + + let contents = toml::to_string_pretty(self).map_err(|e| { + tracing::error!(path = %path.display(), error = %e, "Failed to serialize configuration to TOML"); + ConfigError::ValidationError(format!("Failed to serialize config: {}", e)) + })?; + + std::fs::write(path, &contents).map_err(|e| { + tracing::error!(path = %path.display(), error = %e, "Failed to write configuration file"); + ConfigError::IoError(e) + })?; + + tracing::info!(path = %path.display(), "Successfully saved configuration"); + Ok(()) + } + + /// Validate configuration values. + /// + /// Checks: + /// - API port is in valid range (1024-65535) + /// - API bind address is a valid IP address + /// - Relay URL is valid and uses HTTPS + /// - Registry URL is valid and uses HTTPS + /// - Discovery topic follows `/objects/{network}/{version}/discovery` format + pub fn validate(&self) -> Result<()> { + tracing::debug!("Validating configuration"); + + // Validate API port + if self.node.api_port < 1024 { + return Err(ConfigError::ValidationError(format!( + "API port {} is below 1024 (reserved range)", + self.node.api_port + ))); + } + + // Validate API bind address + self.node.api_bind.parse::().map_err(|e| { + ConfigError::ValidationError(format!("Invalid API bind address: {}", e)) + })?; + + // Validate relay URL + let relay_url = self.network.relay_url.parse::().map_err(|e| { + ConfigError::ValidationError(format!( + "Invalid relay URL '{}': {}", + self.network.relay_url, e + )) + })?; + if relay_url.scheme() != "https" { + return Err(ConfigError::ValidationError(format!( + "Relay URL must use HTTPS, got: {}", + self.network.relay_url + ))); + } + // Validate relay URL has a valid, non-empty host + let host = relay_url.host_str().ok_or_else(|| { + ConfigError::ValidationError(format!( + "Relay URL missing host: {}", + self.network.relay_url + )) + })?; + if host.is_empty() || host == "." { + return Err(ConfigError::ValidationError(format!( + "Relay URL has invalid host: {}", + self.network.relay_url + ))); + } + + // Validate registry URL + let registry_url = self.identity.registry_url.parse::().map_err(|e| { + ConfigError::ValidationError(format!( + "Invalid registry URL '{}': {}", + self.identity.registry_url, e + )) + })?; + if registry_url.scheme() != "https" { + return Err(ConfigError::ValidationError(format!( + "Registry URL must use HTTPS, got: {}", + self.identity.registry_url + ))); + } + // Validate registry URL has a valid, non-empty host + let host = registry_url.host_str().ok_or_else(|| { + ConfigError::ValidationError(format!( + "Registry URL missing host: {}", + self.identity.registry_url + )) + })?; + if host.is_empty() || host == "." { + return Err(ConfigError::ValidationError(format!( + "Registry URL has invalid host: {}", + self.identity.registry_url + ))); + } + + // Validate discovery topic format + if !self.network.discovery_topic.starts_with("/objects/") + || !self.network.discovery_topic.ends_with("/discovery") + { + return Err(ConfigError::ValidationError(format!( + "Discovery topic '{}' must match format '/objects/{{network}}/{{version}}/discovery'", + self.network.discovery_topic + ))); + } + + tracing::debug!("Configuration validation passed"); + Ok(()) + } + + /// Apply environment variable overrides to configuration. + /// + /// Supported environment variables: + /// - `OBJECTS_DATA_DIR` - Overrides node.data_dir + /// - `OBJECTS_API_PORT` - Overrides node.api_port (invalid values logged and ignored) + /// - `OBJECTS_RELAY_URL` - Overrides network.relay_url + /// - `OBJECTS_REGISTRY_URL` - Overrides identity.registry_url + /// + /// Invalid port values are logged as warnings and the default value is retained. + /// All overrides are logged at debug level. Validation occurs after overrides are applied. + fn apply_env_overrides(&mut self) { + if let Ok(data_dir) = std::env::var("OBJECTS_DATA_DIR") { + tracing::debug!(env_var = "OBJECTS_DATA_DIR", value = %data_dir, "Applying environment override"); + self.node.data_dir = data_dir; + } + + if let Ok(api_port_str) = std::env::var("OBJECTS_API_PORT") { + match api_port_str.parse::() { + Ok(port) => { + tracing::debug!( + env_var = "OBJECTS_API_PORT", + value = port, + "Applying environment override" + ); + self.node.api_port = port; + } + Err(e) => { + tracing::warn!( + env_var = "OBJECTS_API_PORT", + value = %api_port_str, + error = %e, + default_port = self.node.api_port, + "Invalid port number in environment variable, using default" + ); + } + } + } + + if let Ok(relay_url) = std::env::var("OBJECTS_RELAY_URL") { + tracing::debug!(env_var = "OBJECTS_RELAY_URL", value = %relay_url, "Applying environment override"); + self.network.relay_url = relay_url; + } + + if let Ok(registry_url) = std::env::var("OBJECTS_REGISTRY_URL") { + tracing::debug!(env_var = "OBJECTS_REGISTRY_URL", value = %registry_url, "Applying environment override"); + self.identity.registry_url = registry_url; + } + } +} + /// Node-specific configuration settings. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeSettings { @@ -153,4 +411,210 @@ mod tests { assert_eq!(config.storage.max_blob_size_mb, 100); assert_eq!(config.storage.max_total_size_gb, 10); } + + #[test] + fn test_load_or_create_missing_file() { + // Ensure no env vars interfere with this test + temp_env::with_vars( + [ + ("OBJECTS_DATA_DIR", None::<&str>), + ("OBJECTS_API_PORT", None::<&str>), + ("OBJECTS_RELAY_URL", None::<&str>), + ("OBJECTS_REGISTRY_URL", None::<&str>), + ], + || { + let temp_dir = tempfile::tempdir().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + // File doesn't exist yet + assert!(!config_path.exists()); + + // Load or create should create the file + let config = NodeConfig::load_or_create(&config_path).unwrap(); + + // File should now exist + assert!(config_path.exists()); + + // Should have default values + assert_eq!(config.node.api_port, 3420); + }, + ); + } + + #[test] + fn test_load_missing_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_path = temp_dir.path().join("missing.toml"); + + // Should return error + let result = NodeConfig::load(&config_path); + assert!(result.is_err()); + } + + #[test] + fn test_save_and_load_roundtrip() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + let mut config = NodeConfig::default(); + config.node.api_port = 8080; + config.node.data_dir = "/custom/path".to_string(); + + // Save + config.save(&config_path).unwrap(); + + // Load + let loaded = NodeConfig::load(&config_path).unwrap(); + + // Verify + assert_eq!(loaded.node.api_port, 8080); + assert_eq!(loaded.node.data_dir, "/custom/path"); + } + + #[test] + fn test_env_overrides() { + temp_env::with_vars( + [ + ("OBJECTS_DATA_DIR", Some("/env/data")), + ("OBJECTS_API_PORT", Some("9000")), + ("OBJECTS_RELAY_URL", Some("https://relay.example.com")), + ("OBJECTS_REGISTRY_URL", Some("https://registry.example.com")), + ], + || { + let config = NodeConfig::from_env().unwrap(); + + assert_eq!(config.node.data_dir, "/env/data"); + assert_eq!(config.node.api_port, 9000); + assert_eq!(config.network.relay_url, "https://relay.example.com"); + assert_eq!(config.identity.registry_url, "https://registry.example.com"); + }, + ); + } + + #[test] + fn test_validation_invalid_port() { + let mut config = NodeConfig::default(); + config.node.api_port = 500; // Below 1024 + + let result = config.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("below 1024")); + } + + #[test] + fn test_validation_invalid_ip() { + let mut config = NodeConfig::default(); + config.node.api_bind = "not-an-ip".to_string(); + + let result = config.validate(); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Invalid API bind address") + ); + } + + #[test] + fn test_validation_non_https_relay() { + let mut config = NodeConfig::default(); + config.network.relay_url = "http://insecure.relay.com".to_string(); + + let result = config.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("must use HTTPS")); + } + + #[test] + fn test_validation_malformed_relay_url() { + let test_cases = vec![ + ("https://", "empty host"), + ("https://.", "invalid host"), + ("not-a-url", "malformed URL"), + ("https:// spaces", "spaces in URL"), + ("", "empty string"), + ]; + + for (invalid_url, description) in test_cases { + let mut config = NodeConfig::default(); + config.network.relay_url = invalid_url.to_string(); + + let result = config.validate(); + assert!( + result.is_err(), + "Expected validation to fail for {}: {}", + description, + invalid_url + ); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("relay URL") || error_msg.contains("Relay URL"), + "Expected error message about relay URL for {}, got: {}", + description, + error_msg + ); + } + } + + #[test] + fn test_validation_non_https_registry() { + let mut config = NodeConfig::default(); + config.identity.registry_url = "http://insecure.registry.com".to_string(); + + let result = config.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("must use HTTPS")); + } + + #[test] + fn test_validation_malformed_registry_url() { + let test_cases = vec![ + ("https://", "empty host"), + ("https://.", "invalid host"), + ("not-a-url", "malformed URL"), + ("", "empty string"), + ]; + + for (invalid_url, description) in test_cases { + let mut config = NodeConfig::default(); + config.identity.registry_url = invalid_url.to_string(); + + let result = config.validate(); + assert!( + result.is_err(), + "Expected validation to fail for {}: {}", + description, + invalid_url + ); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("registry URL") || error_msg.contains("Registry URL"), + "Expected error message about registry URL for {}, got: {}", + description, + error_msg + ); + } + } + + #[test] + fn test_validation_invalid_discovery_topic() { + let mut config = NodeConfig::default(); + config.network.discovery_topic = "/invalid/topic".to_string(); + + let result = config.validate(); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("must match format") + ); + } + + #[test] + fn test_validation_valid_config() { + let config = NodeConfig::default(); + assert!(config.validate().is_ok()); + } }