carp_cli/config/
settings.rs1use crate::utils::error::{CarpError, CarpResult};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5
6#[derive(Clone, Serialize, Deserialize)]
8pub struct Config {
9 pub registry_url: String,
11 pub api_key: Option<String>,
13 #[serde(skip_serializing_if = "Option::is_none")]
15 pub api_token: Option<String>,
16 pub timeout: u64,
18 pub verify_ssl: bool,
20 pub default_output_dir: Option<String>,
22 #[serde(default = "default_max_concurrent_downloads")]
24 pub max_concurrent_downloads: u32,
25 #[serde(default)]
27 pub retry: RetrySettings,
28 #[serde(default)]
30 pub security: SecuritySettings,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct RetrySettings {
36 #[serde(default = "default_max_retries")]
38 pub max_retries: u32,
39 #[serde(default = "default_initial_delay_ms")]
41 pub initial_delay_ms: u64,
42 #[serde(default = "default_max_delay_ms")]
44 pub max_delay_ms: u64,
45 #[serde(default = "default_backoff_multiplier")]
47 pub backoff_multiplier: f64,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SecuritySettings {
53 #[serde(default = "default_max_download_size")]
55 pub max_download_size: u64,
56 #[serde(default = "default_max_publish_size")]
58 pub max_publish_size: u64,
59 #[serde(default)]
61 pub allow_http: bool,
62 #[serde(default = "default_token_warning_hours")]
64 pub token_warning_hours: u64,
65}
66
67fn 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} fn default_max_publish_size() -> u64 {
87 50 * 1024 * 1024
88} fn 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
147pub struct ConfigManager;
149
150impl ConfigManager {
151 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 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 Self::apply_env_overrides(&mut config)?;
181
182 Self::migrate_legacy_token(&mut config)?;
184
185 Self::validate_config(&config)?;
187
188 Ok(config)
189 }
190
191 fn migrate_legacy_token(config: &mut Config) -> CarpResult<()> {
193 if config.api_key.is_none() && config.api_token.is_some() {
195 config.api_key = config.api_token.take();
196 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 fn apply_env_overrides(config: &mut Config) -> CarpResult<()> {
208 if let Ok(url) = std::env::var("CARP_REGISTRY_URL") {
210 config.registry_url = url;
211 }
212
213 if let Ok(api_key) = std::env::var("CARP_API_KEY") {
215 config.api_key = Some(api_key);
216 }
217 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 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 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 if let Ok(output_dir) = std::env::var("CARP_OUTPUT_DIR") {
239 config.default_output_dir = Some(output_dir);
240 }
241
242 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 fn validate_config(config: &Config) -> CarpResult<()> {
254 Self::validate_registry_url(&config.registry_url)?;
256
257 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 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 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 if config.security.max_download_size > 1024 * 1024 * 1024 {
292 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 return Err(CarpError::Config(
301 "Maximum publish size cannot exceed 200MB".to_string(),
302 ));
303 }
304
305 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 fn validate_registry_url(url: &str) -> CarpResult<()> {
319 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 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 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 #[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 #[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; Self::save(&config)
368 }
369
370 pub fn clear_api_key() -> CarpResult<()> {
372 let mut config = Self::load()?;
373 config.api_key = None;
374 config.api_token = None; Self::save(&config)
376 }
377
378 #[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 #[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 #[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 pub fn load_with_env_checks() -> CarpResult<Config> {
408 let config = Self::load()?;
409
410 if Self::is_ci_environment() {
412 eprintln!("Detected CI/CD environment. Using stricter security settings.");
413 }
414
415 if let Some(api_key) = &config.api_key {
417 Self::validate_api_key(api_key)?;
418 }
419
420 Ok(config)
421 }
422
423 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 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 if api_key.len() < 8 {
440 return Err(CarpError::Auth(
441 "API key too short (minimum 8 characters)".to_string(),
442 ));
443 }
444
445 if api_key.contains(['\n', '\r', '\t', ' ']) {
447 return Err(CarpError::Auth(
448 "API key contains invalid characters".to_string(),
449 ));
450 }
451
452 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 pub fn set_api_key_secure(api_key: String) -> CarpResult<()> {
462 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; Self::save(&config)?;
469
470 println!("API key updated successfully.");
471 Ok(())
472 }
473
474 #[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 #[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, api_token: None, 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 #[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 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}