From 888891c91c4cb783cd4a66c0e05d69132f468311 Mon Sep 17 00:00:00 2001 From: Montez Dot <21373685+m-005@users.noreply.github.com> Date: Tue, 20 Jan 2026 21:52:17 -0500 Subject: [PATCH] Config Loading - TOML file I/O and env overrides Continuation of #35 after rebasing to main. --- Cargo.lock | 11 ++ bins/objects-node/Cargo.toml | 4 + bins/objects-node/src/config.rs | 286 ++++++++++++++++++++++++++++++++ 3 files changed, 301 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 87abc25..62e880c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3985,6 +3985,8 @@ dependencies = [ "objects-transport", "serde", "serde_json", + "temp-env", + "tempfile", "thiserror 2.0.17", "tokio", "toml", @@ -6081,6 +6083,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/bins/objects-node/Cargo.toml b/bins/objects-node/Cargo.toml index 30d2db8..ad78b9f 100644 --- a/bins/objects-node/Cargo.toml +++ b/bins/objects-node/Cargo.toml @@ -28,3 +28,7 @@ thiserror.workspace = true # Logging tracing.workspace = true tracing-subscriber.workspace = true + +[dev-dependencies] +tempfile = "3" +temp-env = "0.3" diff --git a/bins/objects-node/src/config.rs b/bins/objects-node/src/config.rs index 33afd42..938c59b 100644 --- a/bins/objects-node/src/config.rs +++ b/bins/objects-node/src/config.rs @@ -1,12 +1,39 @@ //! Configuration types for the OBJECTS node daemon. use serde::{Deserialize, Serialize}; +use std::net::IpAddr; +use std::path::Path; +use thiserror::Error; + +/// 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 +55,130 @@ 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 { + let mut config = Self::default(); + config.apply_env_overrides(); + config.validate()?; + 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 { + if path.exists() { + let mut config = Self::load(path)?; + config.apply_env_overrides(); + config.validate()?; + Ok(config) + } else { + let mut config = Self::default(); + config.apply_env_overrides(); + config.validate()?; + config.save(path)?; + Ok(config) + } + } + + /// Load configuration from a TOML file. + /// + /// Returns an error if the file doesn't exist. + pub fn load(path: &Path) -> Result { + let contents = std::fs::read_to_string(path)?; + let config: Self = toml::from_str(&contents)?; + Ok(config) + } + + /// Save configuration to a TOML file. + /// + /// Creates parent directories if they don't exist. + pub fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let contents = toml::to_string_pretty(self).map_err(|e| { + ConfigError::ValidationError(format!("Failed to serialize config: {}", e)) + })?; + + std::fs::write(path, contents)?; + Ok(()) + } + + /// Validate configuration values. + /// + /// Checks: + /// - API port is in valid range (1024-65535) + /// - API bind address is a valid IP address + /// - Relay URL uses HTTPS + /// - Discovery topic matches RFC-002 format + pub fn validate(&self) -> Result<()> { + // 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 uses HTTPS + if !self.network.relay_url.starts_with("https://") { + return Err(ConfigError::ValidationError( + "Relay URL must use HTTPS".to_string(), + )); + } + + // 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 + ))); + } + + Ok(()) + } + + /// Apply environment variable overrides to configuration. + /// + /// Supported environment variables: + /// - `OBJECTS_DATA_DIR` + /// - `OBJECTS_API_PORT` + /// - `OBJECTS_RELAY_URL` + /// - `OBJECTS_REGISTRY_URL` + fn apply_env_overrides(&mut self) { + if let Ok(data_dir) = std::env::var("OBJECTS_DATA_DIR") { + self.node.data_dir = data_dir; + } + + if let Ok(api_port) = std::env::var("OBJECTS_API_PORT") + && let Ok(port) = api_port.parse::() + { + self.node.api_port = port; + } + + if let Ok(relay_url) = std::env::var("OBJECTS_RELAY_URL") { + self.network.relay_url = relay_url; + } + + if let Ok(registry_url) = std::env::var("OBJECTS_REGISTRY_URL") { + self.identity.registry_url = registry_url; + } + } +} + /// Node-specific configuration settings. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeSettings { @@ -153,4 +304,139 @@ 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_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()); + } }