use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use tracing::debug;
use crate::core::constants;
use crate::core::types::{EncryptedValue, MemberName, PublicKey, SecretKey};
use crate::core::vault;
use crate::error::{ConfigError, Result};
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
pub dugout: Meta,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kms: Option<KmsConfig>,
#[serde(default)]
pub recipients: BTreeMap<MemberName, PublicKey>,
#[serde(default)]
pub secrets: BTreeMap<SecretKey, EncryptedValue>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct KmsConfig {
pub key: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Meta {
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recipients_hash: Option<String>,
}
impl Config {
pub fn new() -> Self {
Self {
dugout: Meta {
version: env!("CARGO_PKG_VERSION").to_string(),
recipients_hash: None,
},
kms: None,
recipients: BTreeMap::new(),
secrets: BTreeMap::new(),
}
}
pub fn config_path_for(vault: Option<&str>) -> PathBuf {
constants::vault_path(vault)
}
pub fn exists_for(vault: Option<&str>) -> bool {
Self::config_path_for(vault).exists()
}
pub fn load_from(vault: Option<&str>) -> Result<Self> {
let path = Self::config_path_for(vault);
debug!(path = %path.display(), "loading config");
if !path.exists() {
return Err(ConfigError::NotInitialized.into());
}
let contents = std::fs::read_to_string(&path).map_err(ConfigError::ReadFile)?;
let config: Self = toml::from_str(&contents).map_err(ConfigError::Parse)?;
debug!(
secrets = config.secrets.len(),
recipients = config.recipients.len(),
"config loaded"
);
config.validate()?;
Ok(config)
}
pub fn save_to(&self, vault: Option<&str>) -> Result<()> {
debug!("saving config");
let contents = toml::to_string_pretty(self).map_err(ConfigError::Serialize)?;
let target_path = Self::config_path_for(vault);
let temp_path = target_path.with_extension("toml.tmp");
std::fs::write(&temp_path, &contents)?;
std::fs::rename(&temp_path, &target_path)?;
Ok(())
}
pub fn config_path() -> PathBuf {
Self::config_path_for(None)
}
pub fn exists() -> bool {
Self::exists_for(None)
}
pub fn load() -> Result<Self> {
Self::load_from(None)
}
pub fn save(&self) -> Result<()> {
self.save_to(None)
}
pub fn project_id(&self) -> String {
std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "default".to_string())
}
pub fn has_kms(&self) -> bool {
self.kms.is_some()
}
pub fn kms_key(&self) -> Option<&str> {
self.kms.as_ref().map(|k| k.key.as_str())
}
pub fn validate(&self) -> Result<()> {
use crate::core::cipher;
debug!("validating config");
if self.dugout.version.is_empty() {
return Err(ConfigError::MissingField { field: "version" }.into());
}
let version_parts: Vec<&str> = self.dugout.version.split('.').collect();
if version_parts.len() < 2 {
return Err(ConfigError::InvalidValue {
field: "version",
reason: format!("not a valid semver: {}", self.dugout.version),
}
.into());
}
if self.recipients.is_empty() {
return Err(ConfigError::NoRecipients.into());
}
for (name, key) in &self.recipients {
if cipher::parse_recipient(key).is_err() {
return Err(ConfigError::InvalidValue {
field: "recipients",
reason: format!("invalid age public key for recipient '{}': {}", name, key),
}
.into());
}
}
for key in self.secrets.keys() {
vault::validate_key(key)?;
}
Ok(())
}
}
impl Default for Config {
fn default() -> Self {
Self::new()
}
}
pub fn ensure_gitignore() -> Result<()> {
let gitignore = std::path::Path::new(".gitignore");
let existing = if gitignore.exists() {
std::fs::read_to_string(gitignore)?
} else {
String::new()
};
let mut updated = existing.clone();
for entry in constants::GITIGNORE_ENTRIES {
if !existing.lines().any(|l| l.trim() == *entry) {
if !updated.is_empty() && !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str(entry);
updated.push('\n');
}
}
if updated != existing {
std::fs::write(gitignore, updated)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
use tempfile::TempDir;
struct TestContext {
_tmp: TempDir,
_original_dir: std::path::PathBuf,
_cwd_guard: MutexGuard<'static, ()>,
}
static CWD_LOCK: Mutex<()> = Mutex::new(());
impl Drop for TestContext {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self._original_dir);
}
}
fn setup_test_dir() -> TestContext {
let cwd_guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().unwrap();
let original_dir =
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/"));
std::env::set_current_dir(tmp.path()).unwrap();
TestContext {
_tmp: tmp,
_original_dir: original_dir,
_cwd_guard: cwd_guard,
}
}
#[test]
fn test_config_save_load_roundtrip() {
let _ctx = setup_test_dir();
let identity = age::x25519::Identity::generate();
let pubkey = identity.to_public().to_string();
let mut config = Config::new();
config.recipients.insert("alice".to_string(), pubkey);
config.secrets.insert(
"TEST_KEY".to_string(),
"-----BEGIN AGE ENCRYPTED FILE-----\ntest\n-----END AGE ENCRYPTED FILE-----"
.to_string(),
);
config.save().unwrap();
assert!(Config::exists());
let loaded = Config::load().unwrap();
assert_eq!(loaded.recipients.len(), 1);
assert_eq!(loaded.secrets.len(), 1);
assert!(loaded.recipients.contains_key("alice"));
assert!(loaded.secrets.contains_key("TEST_KEY"));
}
#[test]
fn test_config_validate_valid() {
let _ctx = setup_test_dir();
let identity = age::x25519::Identity::generate();
let pubkey = identity.to_public().to_string();
let mut config = Config::new();
config.recipients.insert("alice".to_string(), pubkey);
let result = config.validate();
assert!(result.is_ok());
}
#[test]
fn test_config_validate_missing_recipients() {
let _ctx = setup_test_dir();
let config = Config::new();
let result = config.validate();
assert!(result.is_err());
}
#[test]
fn test_config_validate_bad_secret_key() {
let _ctx = setup_test_dir();
let identity = age::x25519::Identity::generate();
let pubkey = identity.to_public().to_string();
let mut config = Config::new();
config.recipients.insert("alice".to_string(), pubkey);
config.secrets.insert(
"invalid-key-name".to_string(),
"encrypted_value".to_string(),
);
let result = config.validate();
assert!(result.is_err());
}
#[test]
fn test_config_path_for_vault() {
assert_eq!(
Config::config_path_for(None),
std::path::PathBuf::from(".dugout.toml")
);
assert_eq!(
Config::config_path_for(Some("dev")),
std::path::PathBuf::from(".dugout.dev.toml")
);
}
}