From 720faf05b130fcf12443ca1b8371c6baaa81d557 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 4 Jun 2026 12:02:43 -0600 Subject: [PATCH] fix: the vault's init_bare should try to load the provisioned secret_provider from the config file without also interpolating any of the rest of the configuration file. It should only fail if the user has not yet created a configuration file; i.e. done a first-time run. --- src/config/install_remote.rs | 68 ++++++++++++++++++++++++++++++++++-- src/config/mod.rs | 2 +- src/vault/mod.rs | 50 ++++++++++++++++++++++---- 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index f07f04e..1c1cd3e 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -731,7 +731,7 @@ fn merge_mcp_json( serde_json::to_string_pretty(&merged).context("failed to serialize merged mcp.json")?; write_atomically(&final_path, &serialized)?; - let vault = Vault::init_bare(); + let vault = Vault::init_bare()?; let (_parsed, missing) = interpolate_secrets(&serialized, &vault)?; let mut deduped: Vec = Vec::new(); for s in missing { @@ -860,7 +860,7 @@ fn handle_missing_secrets(missing: &[String]) -> Result<()> { } fn prompt_for_each_secret(missing: &[String]) -> Result<(Vec, Vec)> { - let mut vault = Vault::init_bare(); + let mut vault = Vault::init_bare()?; let mut password_file_ensured = false; let mut added = Vec::new(); let mut deferred = Vec::new(); @@ -914,6 +914,62 @@ fn print_secret_summary(added: &[String], deferred: &[String]) { #[cfg(test)] mod tests { use super::*; + use crate::utils::get_env_name; + use serial_test::serial; + use std::env; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TestVaultConfigGuard { + dir_key: String, + file_key: String, + previous_dir: Option, + previous_file: Option, + path: PathBuf, + } + + impl TestVaultConfigGuard { + fn new(label: &str) -> Self { + let dir_key = get_env_name("config_dir"); + let file_key = get_env_name("config_file"); + let previous_dir = env::var_os(&dir_key); + let previous_file = env::var_os(&file_key); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = env::temp_dir().join(format!("coyote-vault-test-{label}-{unique}")); + fs::create_dir_all(&path).unwrap(); + let config_path = path.join("config.yaml"); + fs::write(&config_path, "{}").unwrap(); + unsafe { + env::set_var(&dir_key, &path); + env::set_var(&file_key, &config_path); + } + Self { + dir_key, + file_key, + previous_dir, + previous_file, + path, + } + } + } + + impl Drop for TestVaultConfigGuard { + fn drop(&mut self) { + unsafe { + match &self.previous_dir { + Some(p) => env::set_var(&self.dir_key, p), + None => env::remove_var(&self.dir_key), + } + match &self.previous_file { + Some(p) => env::set_var(&self.file_key, p), + None => env::remove_var(&self.file_key), + } + } + let _ = fs::remove_dir_all(&self.path); + } + } #[test] fn parse_url_no_ref() { @@ -1253,7 +1309,9 @@ mod tests { } #[test] + #[serial] fn merge_into_empty_local_adds_all_remote_servers() { + let _guard = TestVaultConfigGuard::new("merge-empty"); let dir = fresh_temp_dir("merge-empty-"); let remote = dir.join("remote.json"); let target = dir.join("target.json"); @@ -1270,7 +1328,9 @@ mod tests { } #[test] + #[serial] fn merge_force_replaces_local_on_conflict() { + let _guard = TestVaultConfigGuard::new("merge-force"); let dir = fresh_temp_dir("merge-force-"); let remote = dir.join("remote.json"); let target = dir.join("target.json"); @@ -1336,7 +1396,9 @@ mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + #[serial] async fn merge_detects_missing_secrets_in_output() { + let _guard = TestVaultConfigGuard::new("merge-secret"); let dir = fresh_temp_dir("merge-secret-"); let remote = dir.join("remote.json"); let target = dir.join("target.json"); @@ -1352,7 +1414,9 @@ mod tests { } #[test] + #[serial] fn merge_is_idempotent_on_re_run() { + let _guard = TestVaultConfigGuard::new("merge-idempotent"); let dir = fresh_temp_dir("merge-idempotent-"); let remote = dir.join("remote.json"); let target = dir.join("target.json"); diff --git a/src/config/mod.rs b/src/config/mod.rs index 0d898ec..9d4fa8c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -685,7 +685,7 @@ pub async fn create_config_file(config_path: &Path) -> Result<()> { let provider_choice = prompt_provider_choice()?; let mut vault = match &provider_choice { - None => Vault::init_bare(), + None => Vault::default_local(), Some(provider) => Vault { provider: provider.clone(), }, diff --git a/src/vault/mod.rs b/src/vault/mod.rs index 65d34e9..8eda382 100644 --- a/src/vault/mod.rs +++ b/src/vault/mod.rs @@ -1,6 +1,9 @@ mod utils; +use std::fs::read_to_string; use std::path::PathBuf; + +use crate::config::paths; pub use utils::create_vault_password_file; pub use utils::interpolate_secrets; pub use utils::prompt_provider_choice; @@ -14,6 +17,7 @@ use gman::providers::SecretProvider; use gman::providers::SupportedProvider; use gman::providers::local::LocalProvider; use inquire::{Password, PasswordDisplayMode, required}; +use serde_yaml::Value; use std::sync::{Arc, LazyLock}; use tokio::runtime::Handle; use uuid::Uuid; @@ -28,17 +32,49 @@ pub struct Vault { pub type GlobalVault = Arc; impl Vault { - pub fn init_bare() -> Self { - let vault_password_file = AppConfig::default().vault_password_file(); - let local_provider = LocalProvider { - password_file: Some(vault_password_file), - git_branch: None, - ..LocalProvider::default() + pub fn init_bare() -> Result { + let config_path = paths::config_file(); + if !config_path.exists() { + bail!( + "Coyote config not found at {}. Run first-run setup before using the vault.", + config_path.display() + ); + } + let content = read_to_string(&config_path) + .with_context(|| format!("failed to read config at {}", config_path.display()))?; + let value: Value = serde_yaml::from_str(&content) + .with_context(|| format!("failed to parse config at {}", config_path.display()))?; + + let provider = match value.get("secrets_provider") { + Some(v) if !v.is_null() => serde_yaml::from_value::(v.clone()) + .with_context(|| "failed to parse 'secrets_provider' from config")?, + _ => { + let password_file = value + .get("vault_password_file") + .and_then(|v| v.as_str()) + .map(PathBuf::from) + .unwrap_or_else(|| AppConfig::default().vault_password_file()); + SupportedProvider::Local { + provider_def: LocalProvider { + password_file: Some(password_file), + git_branch: None, + ..LocalProvider::default() + }, + } + } }; + Ok(Self { provider }) + } + + pub fn default_local() -> Self { Self { provider: SupportedProvider::Local { - provider_def: local_provider, + provider_def: LocalProvider { + password_file: Some(AppConfig::default().vault_password_file()), + git_branch: None, + ..LocalProvider::default() + }, }, } }