use crate::utils::error::{CarpError, CarpResult};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Clone, Serialize, Deserialize)]
pub struct Config {
pub registry_url: String,
pub api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_token: Option<String>,
pub timeout: u64,
pub verify_ssl: bool,
pub default_output_dir: Option<String>,
#[serde(default = "default_max_concurrent_downloads")]
pub max_concurrent_downloads: u32,
#[serde(default)]
pub retry: RetrySettings,
#[serde(default)]
pub security: SecuritySettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrySettings {
#[serde(default = "default_max_retries")]
pub max_retries: u32,
#[serde(default = "default_initial_delay_ms")]
pub initial_delay_ms: u64,
#[serde(default = "default_max_delay_ms")]
pub max_delay_ms: u64,
#[serde(default = "default_backoff_multiplier")]
pub backoff_multiplier: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecuritySettings {
#[serde(default = "default_max_download_size")]
pub max_download_size: u64,
#[serde(default = "default_max_publish_size")]
pub max_publish_size: u64,
#[serde(default)]
pub allow_http: bool,
#[serde(default = "default_token_warning_hours")]
pub token_warning_hours: u64,
}
fn default_max_concurrent_downloads() -> u32 {
4
}
fn default_max_retries() -> u32 {
3
}
fn default_initial_delay_ms() -> u64 {
100
}
fn default_max_delay_ms() -> u64 {
5000
}
fn default_backoff_multiplier() -> f64 {
2.0
}
fn default_max_download_size() -> u64 {
100 * 1024 * 1024
} fn default_max_publish_size() -> u64 {
50 * 1024 * 1024
} fn default_token_warning_hours() -> u64 {
24
}
impl Default for RetrySettings {
fn default() -> Self {
Self {
max_retries: default_max_retries(),
initial_delay_ms: default_initial_delay_ms(),
max_delay_ms: default_max_delay_ms(),
backoff_multiplier: default_backoff_multiplier(),
}
}
}
impl Default for SecuritySettings {
fn default() -> Self {
Self {
max_download_size: default_max_download_size(),
max_publish_size: default_max_publish_size(),
allow_http: false,
token_warning_hours: default_token_warning_hours(),
}
}
}
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("registry_url", &self.registry_url)
.field("api_key", &self.api_key.as_ref().map(|_| "***"))
.field("api_token", &self.api_token.as_ref().map(|_| "***"))
.field("timeout", &self.timeout)
.field("verify_ssl", &self.verify_ssl)
.field("default_output_dir", &self.default_output_dir)
.field("max_concurrent_downloads", &self.max_concurrent_downloads)
.field("retry", &self.retry)
.field("security", &self.security)
.finish()
}
}
impl Default for Config {
fn default() -> Self {
Self {
registry_url: "https://api.carp.refcell.org".to_string(),
api_key: None,
api_token: None,
timeout: 30,
verify_ssl: true,
default_output_dir: None,
max_concurrent_downloads: default_max_concurrent_downloads(),
retry: RetrySettings::default(),
security: SecuritySettings::default(),
}
}
}
pub struct ConfigManager;
impl ConfigManager {
pub fn config_path() -> CarpResult<PathBuf> {
let config_dir = dirs::config_dir()
.ok_or_else(|| CarpError::Config("Unable to find config directory".to_string()))?;
let carp_dir = config_dir.join("carp");
if !carp_dir.exists() {
fs::create_dir_all(&carp_dir)?;
}
Ok(carp_dir.join("config.toml"))
}
pub fn load() -> CarpResult<Config> {
let config_path = Self::config_path()?;
let mut config = if config_path.exists() {
let contents = fs::read_to_string(&config_path)
.map_err(|e| CarpError::Config(format!("Failed to read config file: {e}")))?;
toml::from_str::<Config>(&contents)?
} else {
let default_config = Config::default();
Self::save(&default_config)?;
default_config
};
Self::apply_env_overrides(&mut config)?;
Self::migrate_legacy_token(&mut config)?;
Self::validate_config(&config)?;
Ok(config)
}
fn migrate_legacy_token(config: &mut Config) -> CarpResult<()> {
if config.api_key.is_none() && config.api_token.is_some() {
config.api_key = config.api_token.take();
if let Err(e) = Self::save(config) {
eprintln!("Warning: Failed to save migrated configuration: {e}");
} else {
eprintln!("Info: Migrated api_token to api_key in configuration file.");
}
}
Ok(())
}
fn apply_env_overrides(config: &mut Config) -> CarpResult<()> {
if let Ok(url) = std::env::var("CARP_REGISTRY_URL") {
config.registry_url = url;
}
if let Ok(api_key) = std::env::var("CARP_API_KEY") {
config.api_key = Some(api_key);
}
else if let Ok(api_token) = std::env::var("CARP_API_TOKEN") {
eprintln!("Warning: CARP_API_TOKEN is deprecated. Please use CARP_API_KEY instead.");
config.api_key = Some(api_token);
}
if let Ok(timeout_str) = std::env::var("CARP_TIMEOUT") {
config.timeout = timeout_str
.parse()
.map_err(|_| CarpError::Config("Invalid CARP_TIMEOUT value".to_string()))?;
}
if let Ok(verify_ssl_str) = std::env::var("CARP_VERIFY_SSL") {
config.verify_ssl = verify_ssl_str
.parse()
.map_err(|_| CarpError::Config("Invalid CARP_VERIFY_SSL value".to_string()))?;
}
if let Ok(output_dir) = std::env::var("CARP_OUTPUT_DIR") {
config.default_output_dir = Some(output_dir);
}
if let Ok(allow_http_str) = std::env::var("CARP_ALLOW_HTTP") {
config.security.allow_http = allow_http_str
.parse()
.map_err(|_| CarpError::Config("Invalid CARP_ALLOW_HTTP value".to_string()))?;
}
Ok(())
}
fn validate_config(config: &Config) -> CarpResult<()> {
Self::validate_registry_url(&config.registry_url)?;
if !config.security.allow_http && !config.registry_url.starts_with("https://") {
return Err(CarpError::Config(
"Registry URL must use HTTPS for security. Set allow_http=true in config to override.".to_string()
));
}
if config.timeout == 0 || config.timeout > 300 {
return Err(CarpError::Config(
"Timeout must be between 1 and 300 seconds".to_string(),
));
}
if config.retry.max_retries > 10 {
return Err(CarpError::Config(
"Maximum retries cannot exceed 10".to_string(),
));
}
if config.retry.initial_delay_ms > 60000 {
return Err(CarpError::Config(
"Initial retry delay cannot exceed 60 seconds".to_string(),
));
}
if config.retry.max_delay_ms > 300000 {
return Err(CarpError::Config(
"Maximum retry delay cannot exceed 5 minutes".to_string(),
));
}
if config.security.max_download_size > 1024 * 1024 * 1024 {
return Err(CarpError::Config(
"Maximum download size cannot exceed 1GB".to_string(),
));
}
if config.security.max_publish_size > 200 * 1024 * 1024 {
return Err(CarpError::Config(
"Maximum publish size cannot exceed 200MB".to_string(),
));
}
if !config.verify_ssl {
eprintln!("Warning: SSL verification is disabled. This is insecure and not recommended for production use.");
}
if config.security.allow_http {
eprintln!("Warning: HTTP URLs are allowed. This is insecure and not recommended for production use.");
}
Ok(())
}
fn validate_registry_url(url: &str) -> CarpResult<()> {
if url.is_empty() {
return Err(CarpError::Config(
"Registry URL cannot be empty".to_string(),
));
}
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(CarpError::Config(
"Registry URL must start with http:// or https://".to_string(),
));
}
if url.parse::<reqwest::Url>().is_err() {
return Err(CarpError::Config("Invalid registry URL format".to_string()));
}
Ok(())
}
pub fn save(config: &Config) -> CarpResult<()> {
let config_path = Self::config_path()?;
let contents = toml::to_string_pretty(config)
.map_err(|e| CarpError::Config(format!("Failed to serialize config: {e}")))?;
fs::write(&config_path, contents)
.map_err(|e| CarpError::Config(format!("Failed to write config file: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&config_path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&config_path, perms)?;
}
Ok(())
}
#[allow(dead_code)]
pub fn set_api_key(api_key: String) -> CarpResult<()> {
let mut config = Self::load()?;
config.api_key = Some(api_key);
config.api_token = None; Self::save(&config)
}
pub fn clear_api_key() -> CarpResult<()> {
let mut config = Self::load()?;
config.api_key = None;
config.api_token = None; Self::save(&config)
}
#[deprecated(note = "Use set_api_key instead")]
#[allow(dead_code)]
pub fn set_api_token(token: String) -> CarpResult<()> {
Self::set_api_key(token)
}
#[deprecated(note = "Use clear_api_key instead")]
#[allow(dead_code)]
pub fn clear_api_token() -> CarpResult<()> {
Self::clear_api_key()
}
#[allow(dead_code)]
pub fn cache_dir() -> CarpResult<PathBuf> {
let cache_dir = dirs::cache_dir()
.ok_or_else(|| CarpError::Config("Unable to find cache directory".to_string()))?;
let carp_cache = cache_dir.join("carp");
if !carp_cache.exists() {
fs::create_dir_all(&carp_cache)?;
}
Ok(carp_cache)
}
pub fn load_with_env_checks() -> CarpResult<Config> {
let config = Self::load()?;
if Self::is_ci_environment() {
eprintln!("Detected CI/CD environment. Using stricter security settings.");
}
if let Some(api_key) = &config.api_key {
Self::validate_api_key(api_key)?;
}
Ok(config)
}
fn is_ci_environment() -> bool {
std::env::var("CI").is_ok()
|| std::env::var("GITHUB_ACTIONS").is_ok()
|| std::env::var("GITLAB_CI").is_ok()
|| std::env::var("JENKINS_URL").is_ok()
|| std::env::var("BUILDKITE").is_ok()
}
pub fn validate_api_key(api_key: &str) -> CarpResult<()> {
if api_key.is_empty() {
return Err(CarpError::Auth("Empty API key".to_string()));
}
if api_key.len() < 8 {
return Err(CarpError::Auth(
"API key too short (minimum 8 characters)".to_string(),
));
}
if api_key.contains(['\n', '\r', '\t', ' ']) {
return Err(CarpError::Auth(
"API key contains invalid characters".to_string(),
));
}
if api_key.starts_with("test_") || api_key.starts_with("dev_") {
eprintln!("Warning: API key appears to be for development/testing. Ensure you're using a production key for live environments.");
}
Ok(())
}
pub fn set_api_key_secure(api_key: String) -> CarpResult<()> {
Self::validate_api_key(&api_key)?;
let mut config = Self::load()?;
config.api_key = Some(api_key);
config.api_token = None; Self::save(&config)?;
println!("API key updated successfully.");
Ok(())
}
#[deprecated(note = "Use set_api_key_secure instead")]
#[allow(dead_code)]
pub fn set_api_token_secure(token: String) -> CarpResult<()> {
Self::set_api_key_secure(token)
}
#[allow(dead_code)]
pub fn export_template() -> CarpResult<String> {
let template_config = Config {
registry_url: "${CARP_REGISTRY_URL:-https://api.carp.refcell.org}".to_string(),
api_key: None, api_token: None, timeout: 30,
verify_ssl: true,
default_output_dir: Some("${CARP_OUTPUT_DIR:-./agents}".to_string()),
max_concurrent_downloads: 4,
retry: RetrySettings::default(),
security: SecuritySettings::default(),
};
let template = toml::to_string_pretty(&template_config)
.map_err(|e| CarpError::Config(format!("Failed to generate template: {e}")))?;
Ok(format!(
"# 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}"
))
}
#[allow(dead_code)]
pub fn validate_config_file(path: &PathBuf) -> CarpResult<()> {
if !path.exists() {
return Err(CarpError::Config(format!(
"Configuration file not found: {}",
path.display()
)));
}
let contents = fs::read_to_string(path)
.map_err(|e| CarpError::Config(format!("Failed to read config file: {e}")))?;
let _: toml::Value = toml::from_str(&contents)
.map_err(|e| CarpError::Config(format!("Invalid TOML syntax: {e}")))?;
println!("Configuration file syntax is valid.");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.registry_url, "https://api.carp.refcell.org");
assert!(config.api_token.is_none());
assert_eq!(config.timeout, 30);
assert!(config.verify_ssl);
}
#[test]
fn test_config_serialization() {
let config = Config::default();
let toml_str = toml::to_string(&config).unwrap();
let deserialized: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(config.registry_url, deserialized.registry_url);
assert_eq!(config.timeout, deserialized.timeout);
}
}