carp_cli/config/
settings.rs

1use crate::utils::error::{CarpError, CarpResult};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5
6/// Configuration structure for the Carp CLI
7#[derive(Clone, Serialize, Deserialize)]
8pub struct Config {
9    /// Registry API base URL
10    pub registry_url: String,
11    /// User API key for authentication
12    pub api_key: Option<String>,
13    /// Legacy API token field (deprecated, use api_key instead)
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub api_token: Option<String>,
16    /// Default timeout for API requests in seconds
17    pub timeout: u64,
18    /// Whether to verify SSL certificates
19    pub verify_ssl: bool,
20    /// Default output directory for pulled agents
21    pub default_output_dir: Option<String>,
22    /// Maximum number of concurrent downloads
23    #[serde(default = "default_max_concurrent_downloads")]
24    pub max_concurrent_downloads: u32,
25    /// Request retry configuration
26    #[serde(default)]
27    pub retry: RetrySettings,
28    /// Security settings
29    #[serde(default)]
30    pub security: SecuritySettings,
31}
32
33/// Retry configuration settings
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RetrySettings {
36    /// Maximum number of retries
37    #[serde(default = "default_max_retries")]
38    pub max_retries: u32,
39    /// Initial retry delay in milliseconds
40    #[serde(default = "default_initial_delay_ms")]
41    pub initial_delay_ms: u64,
42    /// Maximum retry delay in milliseconds
43    #[serde(default = "default_max_delay_ms")]
44    pub max_delay_ms: u64,
45    /// Backoff multiplier
46    #[serde(default = "default_backoff_multiplier")]
47    pub backoff_multiplier: f64,
48}
49
50/// Security configuration settings
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SecuritySettings {
53    /// Maximum download size in bytes
54    #[serde(default = "default_max_download_size")]
55    pub max_download_size: u64,
56    /// Maximum publish size in bytes
57    #[serde(default = "default_max_publish_size")]
58    pub max_publish_size: u64,
59    /// Whether to allow HTTP URLs (insecure)
60    #[serde(default)]
61    pub allow_http: bool,
62    /// Token expiry warning threshold in hours
63    #[serde(default = "default_token_warning_hours")]
64    pub token_warning_hours: u64,
65}
66
67// Default value functions
68fn default_max_concurrent_downloads() -> u32 {
69    4
70}
71fn default_max_retries() -> u32 {
72    3
73}
74fn default_initial_delay_ms() -> u64 {
75    100
76}
77fn default_max_delay_ms() -> u64 {
78    5000
79}
80fn default_backoff_multiplier() -> f64 {
81    2.0
82}
83fn default_max_download_size() -> u64 {
84    100 * 1024 * 1024
85} // 100MB
86fn default_max_publish_size() -> u64 {
87    50 * 1024 * 1024
88} // 50MB
89fn default_token_warning_hours() -> u64 {
90    24
91}
92
93impl Default for RetrySettings {
94    fn default() -> Self {
95        Self {
96            max_retries: default_max_retries(),
97            initial_delay_ms: default_initial_delay_ms(),
98            max_delay_ms: default_max_delay_ms(),
99            backoff_multiplier: default_backoff_multiplier(),
100        }
101    }
102}
103
104impl Default for SecuritySettings {
105    fn default() -> Self {
106        Self {
107            max_download_size: default_max_download_size(),
108            max_publish_size: default_max_publish_size(),
109            allow_http: false,
110            token_warning_hours: default_token_warning_hours(),
111        }
112    }
113}
114
115impl std::fmt::Debug for Config {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.debug_struct("Config")
118            .field("registry_url", &self.registry_url)
119            .field("api_key", &self.api_key.as_ref().map(|_| "***"))
120            .field("api_token", &self.api_token.as_ref().map(|_| "***"))
121            .field("timeout", &self.timeout)
122            .field("verify_ssl", &self.verify_ssl)
123            .field("default_output_dir", &self.default_output_dir)
124            .field("max_concurrent_downloads", &self.max_concurrent_downloads)
125            .field("retry", &self.retry)
126            .field("security", &self.security)
127            .finish()
128    }
129}
130
131impl Default for Config {
132    fn default() -> Self {
133        Self {
134            registry_url: "https://api.carp.refcell.org".to_string(),
135            api_key: None,
136            api_token: None,
137            timeout: 30,
138            verify_ssl: true,
139            default_output_dir: None,
140            max_concurrent_downloads: default_max_concurrent_downloads(),
141            retry: RetrySettings::default(),
142            security: SecuritySettings::default(),
143        }
144    }
145}
146
147/// Configuration manager for loading and saving config
148pub struct ConfigManager;
149
150impl ConfigManager {
151    /// Get the path to the config file
152    pub fn config_path() -> CarpResult<PathBuf> {
153        let config_dir = dirs::config_dir()
154            .ok_or_else(|| CarpError::Config("Unable to find config directory".to_string()))?;
155
156        let carp_dir = config_dir.join("carp");
157        if !carp_dir.exists() {
158            fs::create_dir_all(&carp_dir)?;
159        }
160
161        Ok(carp_dir.join("config.toml"))
162    }
163
164    /// Load configuration from file, creating default if it doesn't exist
165    pub fn load() -> CarpResult<Config> {
166        let config_path = Self::config_path()?;
167
168        let mut config = if config_path.exists() {
169            let contents = fs::read_to_string(&config_path)
170                .map_err(|e| CarpError::Config(format!("Failed to read config file: {e}")))?;
171
172            toml::from_str::<Config>(&contents)?
173        } else {
174            let default_config = Config::default();
175            Self::save(&default_config)?;
176            default_config
177        };
178
179        // Override with environment variables if present
180        Self::apply_env_overrides(&mut config)?;
181
182        // Handle backward compatibility: migrate api_token to api_key
183        Self::migrate_legacy_token(&mut config)?;
184
185        // Validate configuration
186        Self::validate_config(&config)?;
187
188        Ok(config)
189    }
190
191    /// Migrate legacy api_token to api_key for backward compatibility
192    fn migrate_legacy_token(config: &mut Config) -> CarpResult<()> {
193        // If we have an api_token but no api_key, migrate it
194        if config.api_key.is_none() && config.api_token.is_some() {
195            config.api_key = config.api_token.take();
196            // Save the migrated config
197            if let Err(e) = Self::save(config) {
198                eprintln!("Warning: Failed to save migrated configuration: {e}");
199            } else {
200                eprintln!("Info: Migrated api_token to api_key in configuration file.");
201            }
202        }
203        Ok(())
204    }
205
206    /// Apply environment variable overrides to configuration
207    fn apply_env_overrides(config: &mut Config) -> CarpResult<()> {
208        // Registry URL
209        if let Ok(url) = std::env::var("CARP_REGISTRY_URL") {
210            config.registry_url = url;
211        }
212
213        // API Key (new environment variable)
214        if let Ok(api_key) = std::env::var("CARP_API_KEY") {
215            config.api_key = Some(api_key);
216        }
217        // API Token (legacy environment variable for backward compatibility)
218        else if let Ok(api_token) = std::env::var("CARP_API_TOKEN") {
219            eprintln!("Warning: CARP_API_TOKEN is deprecated. Please use CARP_API_KEY instead.");
220            config.api_key = Some(api_token);
221        }
222
223        // Timeout
224        if let Ok(timeout_str) = std::env::var("CARP_TIMEOUT") {
225            config.timeout = timeout_str
226                .parse()
227                .map_err(|_| CarpError::Config("Invalid CARP_TIMEOUT value".to_string()))?;
228        }
229
230        // SSL Verification
231        if let Ok(verify_ssl_str) = std::env::var("CARP_VERIFY_SSL") {
232            config.verify_ssl = verify_ssl_str
233                .parse()
234                .map_err(|_| CarpError::Config("Invalid CARP_VERIFY_SSL value".to_string()))?;
235        }
236
237        // Output Directory
238        if let Ok(output_dir) = std::env::var("CARP_OUTPUT_DIR") {
239            config.default_output_dir = Some(output_dir);
240        }
241
242        // Allow HTTP (for development/testing)
243        if let Ok(allow_http_str) = std::env::var("CARP_ALLOW_HTTP") {
244            config.security.allow_http = allow_http_str
245                .parse()
246                .map_err(|_| CarpError::Config("Invalid CARP_ALLOW_HTTP value".to_string()))?;
247        }
248
249        Ok(())
250    }
251
252    /// Validate the complete configuration
253    fn validate_config(config: &Config) -> CarpResult<()> {
254        // Validate registry URL
255        Self::validate_registry_url(&config.registry_url)?;
256
257        // Security checks
258        if !config.security.allow_http && !config.registry_url.starts_with("https://") {
259            return Err(CarpError::Config(
260                "Registry URL must use HTTPS for security. Set allow_http=true in config to override.".to_string()
261            ));
262        }
263
264        // Validate timeout
265        if config.timeout == 0 || config.timeout > 300 {
266            return Err(CarpError::Config(
267                "Timeout must be between 1 and 300 seconds".to_string(),
268            ));
269        }
270
271        // Validate retry settings
272        if config.retry.max_retries > 10 {
273            return Err(CarpError::Config(
274                "Maximum retries cannot exceed 10".to_string(),
275            ));
276        }
277
278        if config.retry.initial_delay_ms > 60000 {
279            return Err(CarpError::Config(
280                "Initial retry delay cannot exceed 60 seconds".to_string(),
281            ));
282        }
283
284        if config.retry.max_delay_ms > 300000 {
285            return Err(CarpError::Config(
286                "Maximum retry delay cannot exceed 5 minutes".to_string(),
287            ));
288        }
289
290        // Validate security settings
291        if config.security.max_download_size > 1024 * 1024 * 1024 {
292            // 1GB
293            return Err(CarpError::Config(
294                "Maximum download size cannot exceed 1GB".to_string(),
295            ));
296        }
297
298        if config.security.max_publish_size > 200 * 1024 * 1024 {
299            // 200MB
300            return Err(CarpError::Config(
301                "Maximum publish size cannot exceed 200MB".to_string(),
302            ));
303        }
304
305        // Warn about insecure settings
306        if !config.verify_ssl {
307            eprintln!("Warning: SSL verification is disabled. This is insecure and not recommended for production use.");
308        }
309
310        if config.security.allow_http {
311            eprintln!("Warning: HTTP URLs are allowed. This is insecure and not recommended for production use.");
312        }
313
314        Ok(())
315    }
316
317    /// Validate registry URL format and security
318    fn validate_registry_url(url: &str) -> CarpResult<()> {
319        // Basic URL validation
320        if url.is_empty() {
321            return Err(CarpError::Config(
322                "Registry URL cannot be empty".to_string(),
323            ));
324        }
325
326        if !url.starts_with("http://") && !url.starts_with("https://") {
327            return Err(CarpError::Config(
328                "Registry URL must start with http:// or https://".to_string(),
329            ));
330        }
331
332        // Parse URL to validate format
333        if url.parse::<reqwest::Url>().is_err() {
334            return Err(CarpError::Config("Invalid registry URL format".to_string()));
335        }
336
337        Ok(())
338    }
339
340    /// Save configuration to file
341    pub fn save(config: &Config) -> CarpResult<()> {
342        let config_path = Self::config_path()?;
343        let contents = toml::to_string_pretty(config)
344            .map_err(|e| CarpError::Config(format!("Failed to serialize config: {e}")))?;
345
346        fs::write(&config_path, contents)
347            .map_err(|e| CarpError::Config(format!("Failed to write config file: {e}")))?;
348
349        // Set restrictive permissions on config file (600 - owner read/write only)
350        #[cfg(unix)]
351        {
352            use std::os::unix::fs::PermissionsExt;
353            let mut perms = fs::metadata(&config_path)?.permissions();
354            perms.set_mode(0o600);
355            fs::set_permissions(&config_path, perms)?;
356        }
357
358        Ok(())
359    }
360
361    /// Update the API key in the config
362    #[allow(dead_code)]
363    pub fn set_api_key(api_key: String) -> CarpResult<()> {
364        let mut config = Self::load()?;
365        config.api_key = Some(api_key);
366        config.api_token = None; // Clear legacy token
367        Self::save(&config)
368    }
369
370    /// Clear the API key from the config
371    pub fn clear_api_key() -> CarpResult<()> {
372        let mut config = Self::load()?;
373        config.api_key = None;
374        config.api_token = None; // Also clear legacy token
375        Self::save(&config)
376    }
377
378    /// Legacy method for backward compatibility
379    #[deprecated(note = "Use set_api_key instead")]
380    #[allow(dead_code)]
381    pub fn set_api_token(token: String) -> CarpResult<()> {
382        Self::set_api_key(token)
383    }
384
385    /// Legacy method for backward compatibility
386    #[deprecated(note = "Use clear_api_key instead")]
387    #[allow(dead_code)]
388    pub fn clear_api_token() -> CarpResult<()> {
389        Self::clear_api_key()
390    }
391
392    /// Get the cache directory for storing downloaded agents
393    #[allow(dead_code)]
394    pub fn cache_dir() -> CarpResult<PathBuf> {
395        let cache_dir = dirs::cache_dir()
396            .ok_or_else(|| CarpError::Config("Unable to find cache directory".to_string()))?;
397
398        let carp_cache = cache_dir.join("carp");
399        if !carp_cache.exists() {
400            fs::create_dir_all(&carp_cache)?;
401        }
402
403        Ok(carp_cache)
404    }
405
406    /// Get configuration with runtime environment checks
407    pub fn load_with_env_checks() -> CarpResult<Config> {
408        let config = Self::load()?;
409
410        // Check for common CI/CD environment variables and adjust settings
411        if Self::is_ci_environment() {
412            eprintln!("Detected CI/CD environment. Using stricter security settings.");
413        }
414
415        // Validate API key if present
416        if let Some(api_key) = &config.api_key {
417            Self::validate_api_key(api_key)?;
418        }
419
420        Ok(config)
421    }
422
423    /// Check if running in a CI/CD environment
424    fn is_ci_environment() -> bool {
425        std::env::var("CI").is_ok()
426            || std::env::var("GITHUB_ACTIONS").is_ok()
427            || std::env::var("GITLAB_CI").is_ok()
428            || std::env::var("JENKINS_URL").is_ok()
429            || std::env::var("BUILDKITE").is_ok()
430    }
431
432    /// Validate API key format and basic security checks
433    pub fn validate_api_key(api_key: &str) -> CarpResult<()> {
434        if api_key.is_empty() {
435            return Err(CarpError::Auth("Empty API key".to_string()));
436        }
437
438        // Basic API key format validation
439        if api_key.len() < 8 {
440            return Err(CarpError::Auth(
441                "API key too short (minimum 8 characters)".to_string(),
442            ));
443        }
444
445        // Check for potentially unsafe characters
446        if api_key.contains(['\n', '\r', '\t', ' ']) {
447            return Err(CarpError::Auth(
448                "API key contains invalid characters".to_string(),
449            ));
450        }
451
452        // Warn about potentially insecure keys
453        if api_key.starts_with("test_") || api_key.starts_with("dev_") {
454            eprintln!("Warning: API key appears to be for development/testing. Ensure you're using a production key for live environments.");
455        }
456
457        Ok(())
458    }
459
460    /// Securely update API key with validation
461    pub fn set_api_key_secure(api_key: String) -> CarpResult<()> {
462        // Validate API key format
463        Self::validate_api_key(&api_key)?;
464
465        let mut config = Self::load()?;
466        config.api_key = Some(api_key);
467        config.api_token = None; // Clear legacy token
468        Self::save(&config)?;
469
470        println!("API key updated successfully.");
471        Ok(())
472    }
473
474    /// Legacy method for backward compatibility
475    #[deprecated(note = "Use set_api_key_secure instead")]
476    #[allow(dead_code)]
477    pub fn set_api_token_secure(token: String) -> CarpResult<()> {
478        Self::set_api_key_secure(token)
479    }
480
481    /// Export configuration template for deployment
482    #[allow(dead_code)]
483    pub fn export_template() -> CarpResult<String> {
484        let template_config = Config {
485            registry_url: "${CARP_REGISTRY_URL:-https://api.carp.refcell.org}".to_string(),
486            api_key: None,   // Never include API keys in templates
487            api_token: None, // Never include legacy tokens in templates
488            timeout: 30,
489            verify_ssl: true,
490            default_output_dir: Some("${CARP_OUTPUT_DIR:-./agents}".to_string()),
491            max_concurrent_downloads: 4,
492            retry: RetrySettings::default(),
493            security: SecuritySettings::default(),
494        };
495
496        let template = toml::to_string_pretty(&template_config)
497            .map_err(|e| CarpError::Config(format!("Failed to generate template: {e}")))?;
498
499        Ok(format!(
500            "# Carp CLI Configuration Template\n# Environment variables will be substituted at runtime\n# Copy this file to ~/.config/carp/config.toml and customize as needed\n# Set CARP_API_KEY environment variable or add api_key field for authentication\n\n{template}"
501        ))
502    }
503
504    /// Validate configuration file without loading sensitive data
505    #[allow(dead_code)]
506    pub fn validate_config_file(path: &PathBuf) -> CarpResult<()> {
507        if !path.exists() {
508            return Err(CarpError::Config(format!(
509                "Configuration file not found: {}",
510                path.display()
511            )));
512        }
513
514        let contents = fs::read_to_string(path)
515            .map_err(|e| CarpError::Config(format!("Failed to read config file: {e}")))?;
516
517        // Parse without loading into full config to check syntax
518        let _: toml::Value = toml::from_str(&contents)
519            .map_err(|e| CarpError::Config(format!("Invalid TOML syntax: {e}")))?;
520
521        println!("Configuration file syntax is valid.");
522        Ok(())
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn test_default_config() {
532        let config = Config::default();
533        assert_eq!(config.registry_url, "https://api.carp.refcell.org");
534        assert!(config.api_token.is_none());
535        assert_eq!(config.timeout, 30);
536        assert!(config.verify_ssl);
537    }
538
539    #[test]
540    fn test_config_serialization() {
541        let config = Config::default();
542        let toml_str = toml::to_string(&config).unwrap();
543        let deserialized: Config = toml::from_str(&toml_str).unwrap();
544
545        assert_eq!(config.registry_url, deserialized.registry_url);
546        assert_eq!(config.timeout, deserialized.timeout);
547    }
548}