use anyhow::{Context, anyhow, bail}; use secrecy::{ExposeSecret, SecretString}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::{env, fs}; use zeroize::Zeroize; use crate::config::{Config, get_config_file_path, load_config}; use crate::providers::git_sync::{ SyncOpts, default_git_email, default_git_username, ensure_git_available, repo_name_from_url, resolve_git, sync_and_push, }; use crate::providers::{SecretProvider, SupportedProvider}; use crate::{ ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION, calling_app_name, }; use anyhow::Result; use argon2::{Algorithm, Argon2, Params, Version}; use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use chacha20poly1305::aead::rand_core::RngCore; use chacha20poly1305::{ Key, XChaCha20Poly1305, XNonce, aead::{Aead, KeyInit, OsRng}, }; use dialoguer::{Input, theme}; use log::{debug, error}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use theme::ColorfulTheme; use validator::Validate; #[skip_serializing_none] /// File-based vault provider with optional Git sync. /// /// This provider stores encrypted envelopes in a per-user configuration /// directory via `confy`. A password is obtained from a configured password /// file or via an interactive prompt. /// /// Example /// ```no_run /// use gman::providers::local::LocalProvider; /// use gman::providers::{SecretProvider, SupportedProvider}; /// use gman::config::Config; /// /// let provider = LocalProvider::default(); /// // Will prompt for a password when reading/writing secrets unless a /// // password file is configured. /// let _ = provider.set_secret("MY_SECRET", "value"); /// ``` #[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct LocalProvider { pub password_file: Option, pub git_branch: Option, pub git_remote_url: Option, pub git_user_name: Option, #[validate(email)] pub git_user_email: Option, pub git_executable: Option, #[serde(skip)] pub runtime_provider_name: Option, } impl Default for LocalProvider { fn default() -> Self { let password_file = match Config::local_provider_password_file() { p if p.exists() => Some(p), _ => None, }; Self { password_file, git_branch: Some("main".into()), git_remote_url: None, git_user_name: None, git_user_email: None, git_executable: None, runtime_provider_name: None, } } } #[async_trait::async_trait] impl SecretProvider for LocalProvider { fn name(&self) -> &'static str { "LocalProvider" } async fn get_secret(&self, key: &str) -> Result { let vault_path = self.active_vault_path()?; let vault: HashMap = load_vault(&vault_path).unwrap_or_default(); let envelope = vault .get(key) .with_context(|| format!("key '{key}' not found in the vault"))?; let password = self.get_password()?; let plaintext = decrypt_string(&password, envelope)?; drop(password); Ok(plaintext) } async fn set_secret(&self, key: &str, value: &str) -> Result<()> { let vault_path = self.active_vault_path()?; let mut vault: HashMap = load_vault(&vault_path).unwrap_or_default(); if vault.contains_key(key) { error!( "Key '{key}' already exists in the vault. Use a different key or delete the existing one first." ); bail!("key '{key}' already exists"); } let password = self.get_password()?; let envelope = encrypt_string(&password, value)?; drop(password); vault.insert(key.to_string(), envelope); store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") } async fn update_secret(&self, key: &str, value: &str) -> Result<()> { let vault_path = self.active_vault_path()?; let mut vault: HashMap = load_vault(&vault_path).unwrap_or_default(); let password = self.get_password()?; let envelope = encrypt_string(&password, value)?; drop(password); if vault.contains_key(key) { debug!("Key '{key}' exists in vault. Overwriting previous value"); let vault_entry = vault .get_mut(key) .with_context(|| format!("key '{key}' not found in the vault"))?; *vault_entry = envelope; return store_vault(&vault_path, &vault) .with_context(|| "failed to save secret to the vault"); } vault.insert(key.to_string(), envelope); store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") } async fn delete_secret(&self, key: &str) -> Result<()> { let vault_path = self.active_vault_path()?; let mut vault: HashMap = load_vault(&vault_path).unwrap_or_default(); if !vault.contains_key(key) { error!("Key '{key}' does not exist in the vault."); bail!("key '{key}' does not exist"); } vault.remove(key); store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") } async fn list_secrets(&self) -> Result> { let vault_path = self.active_vault_path()?; let vault: HashMap = load_vault(&vault_path).unwrap_or_default(); let keys: Vec = vault.keys().cloned().collect(); Ok(keys) } async fn sync(&mut self) -> Result<()> { let mut config_changed = false; let git = resolve_git(self.git_executable.as_ref())?; ensure_git_available(&git)?; if self.git_branch.is_none() { config_changed = true; debug!("Prompting user to set git_branch in config for sync"); let branch: String = Input::with_theme(&ColorfulTheme::default()) .with_prompt("Enter git branch to sync with") .default("main".into()) .interact_text()?; self.git_branch = Some(branch); } if self.git_remote_url.is_none() { config_changed = true; debug!("Prompting user to set git_remote in config for sync"); let remote: String = Input::with_theme(&ColorfulTheme::default()) .with_prompt( "Enter remote git URL to sync with (e.g. 'git@github.com:user/repo.git')", ) .validate_with(|s: &String| { LocalProvider { git_remote_url: Some(s.clone()), ..LocalProvider::default() } .validate() .map(|_| ()) .map_err(|e| e.to_string()) }) .interact_text()?; self.git_remote_url = Some(remote); } if self.git_user_name.is_none() { config_changed = true; debug!("Prompting user git user name"); let default_user_name = default_git_username(&git)?.trim().to_string(); let branch: String = Input::with_theme(&ColorfulTheme::default()) .with_prompt("Enter git user name") .default(default_user_name) .interact_text()?; self.git_user_name = Some(branch); } if self.git_user_email.is_none() { config_changed = true; debug!("Prompting user git email"); let default_user_name = default_git_email(&git)?.trim().to_string(); let branch: String = Input::with_theme(&ColorfulTheme::default()) .with_prompt("Enter git user email") .validate_with({ |s: &String| { if s.contains('@') { Ok(()) } else { Err("not a valid email address".to_string()) } } }) .default(default_user_name) .interact_text()?; self.git_user_email = Some(branch); } if config_changed { self.persist_git_settings_to_config()?; } let sync_opts = SyncOpts { remote_url: &self.git_remote_url, branch: &self.git_branch, user_name: &self.git_user_name, user_email: &self.git_user_email, git_executable: &self.git_executable, }; sync_and_push(&sync_opts) } } impl LocalProvider { fn persist_git_settings_to_config(&self) -> Result<()> { debug!("Saving updated config (only current local provider)"); let mut cfg = load_config(true).with_context(|| "failed to load existing config")?; let target_name = self.runtime_provider_name.clone(); let mut updated = false; for pc in cfg.providers.iter_mut() { if let SupportedProvider::Local { provider_def } = &mut pc.provider_type { let matches_name = match (&pc.name, &target_name) { (Some(n), Some(t)) => n == t, (Some(_), None) => false, _ => false, }; if matches_name || target_name.is_none() { provider_def.git_branch = self.git_branch.clone(); provider_def.git_remote_url = self.git_remote_url.clone(); provider_def.git_user_name = self.git_user_name.clone(); provider_def.git_user_email = self.git_user_email.clone(); provider_def.git_executable = self.git_executable.clone(); updated = true; if matches_name { break; } } } } if !updated { bail!("unable to find matching local provider in config to update"); } let path = get_config_file_path()?; let ext = path.extension().and_then(|s| s.to_str()).unwrap_or(""); if ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml") { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let s = serde_yaml::to_string(&cfg)?; fs::write(&path, s).with_context(|| format!("failed to write {}", path.display()))?; } else { confy::store(&calling_app_name(), "config", &cfg) .with_context(|| "failed to save updated config via confy")?; } Ok(()) } fn repo_dir_for_config(&self) -> Result> { if let Some(remote) = &self.git_remote_url { let name = repo_name_from_url(remote); let dir = base_config_dir()?.join(format!(".{}", name)); Ok(Some(dir)) } else { Ok(None) } } fn active_vault_path(&self) -> Result { if let Some(dir) = self.repo_dir_for_config()? && dir.exists() { return Ok(dir.join("vault.yml")); } default_vault_path() } fn get_password(&self) -> Result { if let Some(password_file) = &self.password_file { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let metadata = fs::metadata(password_file).with_context(|| { format!("failed to read password file metadata {:?}", password_file) })?; let mode = metadata.permissions().mode(); if mode & 0o077 != 0 { bail!( "password file {:?} has insecure permissions {:o} (should be 0600 or 0400)", password_file, mode & 0o777 ); } } let password = SecretString::new( fs::read_to_string(password_file) .with_context(|| format!("failed to read password file {:?}", password_file))? .trim() .to_string() .into(), ); Ok(password) } else { let password = rpassword::prompt_password("\nPassword: ")?; Ok(SecretString::new(password.into())) } } } fn default_vault_path() -> Result { let xdg_path = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from); if let Some(xdg) = xdg_path { return Ok(xdg.join(calling_app_name()).join("vault.yml")); } confy::get_configuration_file_path(&calling_app_name(), "vault") .with_context(|| "get config dir") } fn base_config_dir() -> Result { default_vault_path()? .parent() .map(Path::to_path_buf) .ok_or_else(|| anyhow!("Failed to determine config dir")) } fn load_vault(path: &Path) -> Result> { if !path.exists() { return Ok(HashMap::new()); } let s = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; let map: HashMap = serde_yaml::from_str(&s).unwrap_or_default(); Ok(map) } fn store_vault(path: &Path, map: &HashMap) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } let s = serde_yaml::to_string(map).with_context(|| "serialize vault")?; fs::write(path, &s).with_context(|| format!("write {}", path.display()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(path, fs::Permissions::from_mode(0o600)) .with_context(|| format!("set permissions on {}", path.display()))?; } Ok(()) } fn encrypt_string(password: &SecretString, plaintext: &str) -> Result { if password.expose_secret().is_empty() { bail!("password cannot be empty"); } let mut salt = [0u8; SALT_LEN]; OsRng.fill_bytes(&mut salt); let mut nonce_bytes = [0u8; NONCE_LEN]; OsRng.fill_bytes(&mut nonce_bytes); let mut key = derive_key(password, &salt)?; let cipher = XChaCha20Poly1305::new(&key); let aad = format!( "{};{};{};m={},t={},p={}", HEADER, VERSION, KDF, ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P ); let nonce: XNonce = nonce_bytes.into(); let mut pt = plaintext.as_bytes().to_vec(); let ct = cipher .encrypt( &nonce, chacha20poly1305::aead::Payload { msg: &pt, aad: aad.as_bytes(), }, ) .map_err(|_| anyhow!("encryption failed"))?; pt.zeroize(); let env = format!( "{};{};{};m={m},t={t},p={p};salt={salt};nonce={nonce};ct={ct}", HEADER, VERSION, KDF, m = ARGON_M_COST_KIB, t = ARGON_T_COST, p = ARGON_P, salt = B64.encode(salt), nonce = B64.encode(nonce_bytes), ct = B64.encode(&ct), ); drop(cipher); key.zeroize(); salt.zeroize(); nonce_bytes.zeroize(); Ok(env) } fn derive_key_with_params( password: &SecretString, salt: &[u8], m_cost: u32, t_cost: u32, p: u32, ) -> Result { let params = Params::new(m_cost, t_cost, p, Some(KEY_LEN)) .map_err(|e| anyhow!("argon2 params error: {:?}", e))?; let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let mut key_bytes = [0u8; KEY_LEN]; argon .hash_password_into(password.expose_secret().as_bytes(), salt, &mut key_bytes) .map_err(|e| anyhow!("argon2 derive error: {:?}", e))?; let key: Key = key_bytes.into(); key_bytes.zeroize(); Ok(key) } fn derive_key(password: &SecretString, salt: &[u8]) -> Result { derive_key_with_params(password, salt, ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P) } /// Attempts to decrypt with the given cipher, nonce, ciphertext, and AAD. fn try_decrypt( cipher: &XChaCha20Poly1305, nonce: &XNonce, ct: &[u8], aad: &[u8], ) -> std::result::Result, chacha20poly1305::aead::Error> { cipher.decrypt(nonce, chacha20poly1305::aead::Payload { msg: ct, aad }) } type EnvelopeComponents = (u32, u32, u32, Vec, [u8; NONCE_LEN], Vec); /// Parse an envelope string and extract its components. /// Returns (m, t, p, salt, nonce_arr, ct) on success. fn parse_envelope(envelope: &str) -> Result { let parts: Vec<&str> = envelope.trim().split(';').collect(); if parts.len() < 7 { debug!("Invalid envelope format: {:?}", parts); bail!("invalid envelope format"); } if parts[0] != HEADER { debug!("Invalid header: {}", parts[0]); bail!("unexpected header"); } if parts[1] != VERSION { debug!("Unsupported version: {}", parts[1]); bail!("unsupported version {}", parts[1]); } if parts[2] != KDF { debug!("Unsupported kdf: {}", parts[2]); bail!("unsupported kdf {}", parts[2]); } let params_str = parts[3]; let mut m = ARGON_M_COST_KIB; let mut t = ARGON_T_COST; let mut p = ARGON_P; for kv in params_str.split(',') { if let Some((k, v)) = kv.split_once('=') { match k { "m" => m = v.parse().unwrap_or(m), "t" => t = v.parse().unwrap_or(t), "p" => p = v.parse().unwrap_or(p), _ => {} } } } let salt_b64 = parts[4] .strip_prefix("salt=") .with_context(|| "missing salt")?; let nonce_b64 = parts[5] .strip_prefix("nonce=") .with_context(|| "missing nonce")?; let ct_b64 = parts[6].strip_prefix("ct=").with_context(|| "missing ct")?; let salt = B64.decode(salt_b64).with_context(|| "bad salt b64")?; let nonce_bytes = B64.decode(nonce_b64).with_context(|| "bad nonce b64")?; let ct = B64.decode(ct_b64).with_context(|| "bad ct b64")?; if nonce_bytes.len() != NONCE_LEN { debug!("Nonce length mismatch: {}", nonce_bytes.len()); bail!("nonce length mismatch"); } let nonce_arr: [u8; NONCE_LEN] = nonce_bytes .try_into() .map_err(|_| anyhow!("invalid nonce length"))?; Ok((m, t, p, salt, nonce_arr, ct)) } fn decrypt_string(password: &SecretString, envelope: &str) -> Result { if password.expose_secret().is_empty() { bail!("password cannot be empty"); } let (m, t, p, mut salt, mut nonce_arr, mut ct) = parse_envelope(envelope)?; let nonce: XNonce = nonce_arr.into(); let aad_current = format!("{};{};{};m={},t={},p={}", HEADER, VERSION, KDF, m, t, p); let mut key = derive_key_with_params(password, &salt, m, t, p)?; let cipher = XChaCha20Poly1305::new(&key); if let Ok(pt) = try_decrypt(&cipher, &nonce, &ct, aad_current.as_bytes()) { let s = String::from_utf8(pt.clone()).with_context(|| "plaintext not valid UTF-8")?; key.zeroize(); salt.zeroize(); nonce_arr.zeroize(); ct.zeroize(); return Ok(s); } key.zeroize(); salt.zeroize(); nonce_arr.zeroize(); ct.zeroize(); // TODO: Remove once all users have migrated their local vaults if let Ok(plaintext) = legacy::decrypt_string_legacy(password, envelope) { return Ok(plaintext); } bail!("decryption failed (wrong password or corrupted data)") } // TODO: Remove this entire module once all users have migrated their vaults. mod legacy { use super::*; fn legacy_aad() -> String { format!("{};{}", HEADER, VERSION) } pub fn decrypt_string_legacy(password: &SecretString, envelope: &str) -> Result { if password.expose_secret().is_empty() { bail!("password cannot be empty"); } let (m, t, p, mut salt, mut nonce_arr, mut ct) = parse_envelope(envelope)?; let nonce: XNonce = nonce_arr.into(); let aad = legacy_aad(); let mut key = derive_key_with_params(password, &salt, m, t, p)?; let cipher = XChaCha20Poly1305::new(&key); if let Ok(pt) = try_decrypt(&cipher, &nonce, &ct, aad.as_bytes()) { let s = String::from_utf8(pt.clone()).with_context(|| "plaintext not valid UTF-8")?; key.zeroize(); salt.zeroize(); nonce_arr.zeroize(); ct.zeroize(); return Ok(s); } key.zeroize(); let mut zeros_key: Key = [0u8; KEY_LEN].into(); let zeros_cipher = XChaCha20Poly1305::new(&zeros_key); if let Ok(pt) = try_decrypt(&zeros_cipher, &nonce, &ct, aad.as_bytes()) { debug!("Decrypted using legacy all-zeros key - secret needs migration"); let s = String::from_utf8(pt.clone()).with_context(|| "plaintext not valid UTF-8")?; zeros_key.zeroize(); salt.zeroize(); nonce_arr.zeroize(); ct.zeroize(); return Ok(s); } zeros_key.zeroize(); salt.zeroize(); nonce_arr.zeroize(); ct.zeroize(); bail!("legacy decryption failed") } pub fn is_current_format(password: &SecretString, envelope: &str) -> Result { if password.expose_secret().is_empty() { bail!("password cannot be empty"); } let (m, t, p, salt, nonce_arr, ct) = parse_envelope(envelope)?; let nonce: XNonce = nonce_arr.into(); let aad_current = format!("{};{};{};m={},t={},p={}", HEADER, VERSION, KDF, m, t, p); let key = derive_key_with_params(password, &salt, m, t, p)?; let cipher = XChaCha20Poly1305::new(&key); Ok(try_decrypt(&cipher, &nonce, &ct, aad_current.as_bytes()).is_ok()) } } // TODO: Remove once all users have migrated their local vaults #[derive(Debug, Clone, PartialEq, Eq)] pub enum SecretStatus { Current, NeedsMigration, } // TODO: Remove once all users have migrated their local vaults #[derive(Debug)] pub struct MigrationResult { pub total: usize, pub migrated: usize, pub already_current: usize, pub failed: Vec<(String, String)>, } impl LocalProvider { // TODO: Remove once all users have migrated their local vaults pub async fn migrate_vault(&self) -> Result { let vault_path = self.active_vault_path()?; let vault: HashMap = load_vault(&vault_path).unwrap_or_default(); if vault.is_empty() { return Ok(MigrationResult { total: 0, migrated: 0, already_current: 0, failed: vec![], }); } let password = self.get_password()?; let mut migrated_vault = HashMap::new(); let mut migrated_count = 0; let mut already_current_count = 0; let mut failed = vec![]; for (key, envelope) in &vault { match legacy::is_current_format(&password, envelope) { Ok(true) => { migrated_vault.insert(key.clone(), envelope.clone()); already_current_count += 1; } Ok(false) => match decrypt_string(&password, envelope) { Ok(plaintext) => match encrypt_string(&password, &plaintext) { Ok(new_envelope) => { migrated_vault.insert(key.clone(), new_envelope); migrated_count += 1; } Err(e) => { failed.push((key.clone(), format!("re-encryption failed: {}", e))); migrated_vault.insert(key.clone(), envelope.clone()); } }, Err(e) => { failed.push((key.clone(), format!("decryption failed: {}", e))); migrated_vault.insert(key.clone(), envelope.clone()); } }, Err(e) => { failed.push((key.clone(), format!("status check failed: {}", e))); migrated_vault.insert(key.clone(), envelope.clone()); } } } if migrated_count > 0 { store_vault(&vault_path, &migrated_vault)?; } Ok(MigrationResult { total: vault.len(), migrated: migrated_count, already_current: already_current_count, failed, }) } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; use secrecy::{ExposeSecret, SecretString}; use std::env as std_env; use tempfile::tempdir; #[test] fn test_derive_key() { let password = SecretString::new("test_password".to_string().into()); let salt = [0u8; 16]; let key = derive_key(&password, &salt).unwrap(); assert_eq!(key.len(), 32); } #[test] fn test_derive_key_with_params() { let password = SecretString::new("test_password".to_string().into()); let salt = [0u8; 16]; let key = derive_key_with_params(&password, &salt, 10, 1, 1).unwrap(); assert_eq!(key.len(), 32); } #[test] fn crypto_roundtrip_local_impl() { let pw = SecretString::new("pw".into()); let msg = "hello world"; let env = encrypt_string(&pw, msg).unwrap(); let out = decrypt_string(&pw, &env).unwrap(); assert_eq!(out, msg); } #[test] #[cfg(unix)] fn get_password_reads_password_file() { use std::os::unix::fs::PermissionsExt; let dir = tempdir().unwrap(); let file = dir.path().join("pw.txt"); fs::write(&file, "secretpw\n").unwrap(); fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap(); let provider = LocalProvider { password_file: Some(file), runtime_provider_name: None, ..LocalProvider::default() }; let pw = provider.get_password().unwrap(); assert_eq!(pw.expose_secret(), "secretpw"); } #[test] #[cfg(unix)] fn get_password_rejects_insecure_file() { use std::os::unix::fs::PermissionsExt; let dir = tempdir().unwrap(); let file = dir.path().join("pw.txt"); fs::write(&file, "secretpw\n").unwrap(); fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap(); let provider = LocalProvider { password_file: Some(file), runtime_provider_name: None, ..LocalProvider::default() }; assert!(provider.get_password().is_err()); } #[test] #[cfg(not(unix))] fn get_password_reads_password_file() { let dir = tempdir().unwrap(); let file = dir.path().join("pw.txt"); fs::write(&file, "secretpw\n").unwrap(); let provider = LocalProvider { password_file: Some(file), runtime_provider_name: None, ..LocalProvider::default() }; let pw = provider.get_password().unwrap(); assert_eq!(pw.expose_secret(), "secretpw"); } #[test] fn persist_only_target_local_provider_git_settings() { let td = tempdir().unwrap(); let xdg = td.path().join("xdg"); let app_dir = xdg.join(calling_app_name()); fs::create_dir_all(&app_dir).unwrap(); unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg); } let initial_yaml = indoc::indoc! { "--- default_provider: local providers: - name: local type: local password_file: /tmp/.gman_pass git_branch: main git_remote_url: null git_user_name: null git_user_email: null git_executable: null - name: other type: local git_branch: main git_remote_url: git@github.com:someone/else.git run_configs: - name: echo secrets: [API_KEY] " }; let cfg_path = app_dir.join("config.yml"); fs::write(&cfg_path, initial_yaml).unwrap(); let provider = LocalProvider { password_file: None, git_branch: Some("dev".into()), git_remote_url: Some("git@github.com:user/repo.git".into()), git_user_name: Some("Test User".into()), git_user_email: Some("test@example.com".into()), git_executable: Some(PathBuf::from("/usr/bin/git")), runtime_provider_name: Some("local".into()), }; provider .persist_git_settings_to_config() .expect("persist ok"); let content = fs::read_to_string(&cfg_path).unwrap(); let cfg: Config = serde_yaml::from_str(&content).unwrap(); assert_eq!(cfg.default_provider.as_deref(), Some("local")); assert!(cfg.run_configs.is_some()); assert_eq!(cfg.run_configs.as_ref().unwrap().len(), 1); let p0 = &cfg.providers[0]; assert_eq!(p0.name.as_deref(), Some("local")); match &p0.provider_type { SupportedProvider::Local { provider_def } => { assert_eq!(provider_def.git_branch.as_deref(), Some("dev")); assert_eq!( provider_def.git_remote_url.as_deref(), Some("git@github.com:user/repo.git") ); assert_eq!(provider_def.git_user_name.as_deref(), Some("Test User")); assert_eq!( provider_def.git_user_email.as_deref(), Some("test@example.com") ); assert_eq!( provider_def.git_executable.as_ref(), Some(&PathBuf::from("/usr/bin/git")) ); } _ => panic!("expected local provider"), } let p1 = &cfg.providers[1]; assert_eq!(p1.name.as_deref(), Some("other")); match &p1.provider_type { SupportedProvider::Local { provider_def } => { assert_eq!(provider_def.git_branch.as_deref(), Some("main")); assert_eq!( provider_def.git_remote_url.as_deref(), Some("git@github.com:someone/else.git") ); } _ => panic!("expected local provider"), } unsafe { std_env::remove_var("XDG_CONFIG_HOME"); } } }