refactor: Refactor configuration structs directly into the provider definition to simplify validation, structs, and future extensions

This commit is contained in:
2025-09-12 15:37:17 -06:00
parent cb1a97471f
commit 9d8fa17dde
13 changed files with 1535 additions and 347 deletions
Generated
+1297 -48
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gman" name = "gman"
version = "0.2.0" version = "0.1.0"
edition = "2024" edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"] authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "Universal secret management and injection tool" description = "Universal secret management and injection tool"
@@ -49,6 +49,9 @@ indoc = "2.0.6"
regex = "1.11.2" regex = "1.11.2"
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
tempfile = "3.22.0" tempfile = "3.22.0"
aws-sdk-secretsmanager = "1.88.0"
tokio = { version = "1.47.1", features = ["full"] }
aws-config = { version = "1.8.6", features = ["behavior-version-latest"] }
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
+14 -15
View File
@@ -1,6 +1,6 @@
use crate::command::preview_command; use crate::command::preview_command;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use gman::config::{Config, ProviderConfig, RunConfig}; use gman::config::{Config, RunConfig};
use gman::providers::SecretProvider; use gman::providers::SecretProvider;
use heck::ToSnakeCase; use heck::ToSnakeCase;
use log::{debug, error}; use log::{debug, error};
@@ -15,9 +15,8 @@ const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}";
const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}"; const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}";
pub fn wrap_and_run_command( pub fn wrap_and_run_command(
secrets_provider: Box<dyn SecretProvider>, secrets_provider: &mut dyn SecretProvider,
config: &Config, config: &Config,
provider_config: &ProviderConfig,
tokens: Vec<OsString>, tokens: Vec<OsString>,
profile_name: Option<String>, profile_name: Option<String>,
dry_run: bool, dry_run: bool,
@@ -51,7 +50,7 @@ pub fn wrap_and_run_command(
run_config_profile_name run_config_profile_name
); );
secrets_provider secrets_provider
.get_secret(provider_config, key.to_snake_case().to_uppercase().as_str()) .get_secret(key.to_snake_case().to_uppercase().as_str())
.ok() .ok()
.map_or_else( .map_or_else(
|| { || {
@@ -254,7 +253,7 @@ pub fn parse_args(
mod tests { mod tests {
use super::*; use super::*;
use crate::cli::generate_files_secret_injections; use crate::cli::generate_files_secret_injections;
use gman::config::{Config, ProviderConfig, RunConfig}; use gman::config::{Config, RunConfig};
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::OsString; use std::ffi::OsString;
@@ -264,16 +263,16 @@ mod tests {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"Dummy" "Dummy"
} }
fn get_secret(&self, _config: &ProviderConfig, key: &str) -> Result<String> { fn get_secret(&self, key: &str) -> Result<String> {
Ok(format!("{}_VAL", key)) Ok(format!("{}_VAL", key))
} }
fn set_secret(&self, _config: &ProviderConfig, _key: &str, _value: &str) -> Result<()> { fn set_secret(&self, _key: &str, _value: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn delete_secret(&self, _config: &ProviderConfig, _key: &str) -> Result<()> { fn delete_secret(&self, _key: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn sync(&self, _config: &mut ProviderConfig) -> Result<()> { fn sync(&mut self) -> Result<()> {
Ok(()) Ok(())
} }
} }
@@ -345,10 +344,10 @@ mod tests {
#[test] #[test]
fn test_wrap_and_run_command_no_profile() { fn test_wrap_and_run_command_no_profile() {
let cfg = Config::default(); let cfg = Config::default();
let provider_cfg = ProviderConfig::default(); let mut dummy = DummyProvider;
let prov: Box<dyn SecretProvider> = Box::new(DummyProvider); let prov: &mut dyn SecretProvider = &mut dummy;
let tokens = vec![OsString::from("echo"), OsString::from("hi")]; let tokens = vec![OsString::from("echo"), OsString::from("hi")];
let err = wrap_and_run_command(prov, &cfg, &provider_cfg, tokens, None, true).unwrap_err(); let err = wrap_and_run_command(prov, &cfg, tokens, None, true).unwrap_err();
assert!(err.to_string().contains("No run profile found")); assert!(err.to_string().contains("No run profile found"));
} }
@@ -367,13 +366,13 @@ mod tests {
run_configs: Some(vec![run_cfg]), run_configs: Some(vec![run_cfg]),
..Config::default() ..Config::default()
}; };
let provider_cfg = ProviderConfig::default(); let mut dummy = DummyProvider;
let prov: Box<dyn SecretProvider> = Box::new(DummyProvider); let prov: &mut dyn SecretProvider = &mut dummy;
// Capture stderr for dry_run preview // Capture stderr for dry_run preview
let tokens = vec![OsString::from("echo"), OsString::from("hello")]; let tokens = vec![OsString::from("echo"), OsString::from("hello")];
// Best-effort: ensure function does not error under dry_run // Best-effort: ensure function does not error under dry_run
let res = wrap_and_run_command(prov, &cfg, &provider_cfg, tokens, None, true); let res = wrap_and_run_command(prov, &cfg, tokens, None, true);
assert!(res.is_ok()); assert!(res.is_ok());
// Not asserting output text to keep test platform-agnostic // Not asserting output text to keep test platform-agnostic
} }
+13 -21
View File
@@ -116,7 +116,8 @@ enum Commands {
}, },
} }
fn main() -> Result<()> { #[tokio::main]
async fn main() -> Result<()> {
if let Err(e) = log4rs::init_config(utils::init_logging_config()) { if let Err(e) = log4rs::init_config(utils::init_logging_config()) {
eprintln!("Failed to initialize logging: {e}"); eprintln!("Failed to initialize logging: {e}");
} }
@@ -144,7 +145,7 @@ fn main() -> Result<()> {
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?; read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
let snake_case_name = name.to_snake_case().to_uppercase(); let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider secrets_provider
.set_secret(&provider_config, &snake_case_name, plaintext.trim_end()) .set_secret(&snake_case_name, plaintext.trim_end())
.map(|_| match cli.output { .map(|_| match cli.output {
Some(_) => (), Some(_) => (),
None => println!("✓ Secret '{snake_case_name}' added to the vault."), None => println!("✓ Secret '{snake_case_name}' added to the vault."),
@@ -153,7 +154,7 @@ fn main() -> Result<()> {
Commands::Get { name } => { Commands::Get { name } => {
let snake_case_name = name.to_snake_case().to_uppercase(); let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider secrets_provider
.get_secret(&provider_config, &snake_case_name) .get_secret(&snake_case_name)
.map(|secret| match cli.output { .map(|secret| match cli.output {
Some(OutputFormat::Json) => { Some(OutputFormat::Json) => {
let json_output = serde_json::json!({ let json_output = serde_json::json!({
@@ -175,7 +176,7 @@ fn main() -> Result<()> {
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?; read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
let snake_case_name = name.to_snake_case().to_uppercase(); let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider secrets_provider
.update_secret(&provider_config, &snake_case_name, plaintext.trim_end()) .update_secret(&snake_case_name, plaintext.trim_end())
.map(|_| match cli.output { .map(|_| match cli.output {
Some(_) => (), Some(_) => (),
None => println!("✓ Secret '{snake_case_name}' updated in the vault."), None => println!("✓ Secret '{snake_case_name}' updated in the vault."),
@@ -183,16 +184,14 @@ fn main() -> Result<()> {
} }
Commands::Delete { name } => { Commands::Delete { name } => {
let snake_case_name = name.to_snake_case().to_uppercase(); let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider secrets_provider.delete_secret(&snake_case_name).map(|_| {
.delete_secret(&provider_config, &snake_case_name) if cli.output.is_none() {
.map(|_| { println!("✓ Secret '{snake_case_name}' deleted from the vault.")
if cli.output.is_none() { }
println!("✓ Secret '{snake_case_name}' deleted from the vault.") })?;
}
})?;
} }
Commands::List {} => { Commands::List {} => {
let secrets = secrets_provider.list_secrets(&provider_config)?; let secrets = secrets_provider.list_secrets()?;
if secrets.is_empty() { if secrets.is_empty() {
match cli.output { match cli.output {
Some(OutputFormat::Json) => { Some(OutputFormat::Json) => {
@@ -218,21 +217,14 @@ fn main() -> Result<()> {
} }
} }
Commands::Sync {} => { Commands::Sync {} => {
secrets_provider.sync(&mut provider_config).map(|_| { secrets_provider.sync().map(|_| {
if cli.output.is_none() { if cli.output.is_none() {
println!("✓ Secrets synchronized with remote") println!("✓ Secrets synchronized with remote")
} }
})?; })?;
} }
Commands::External(tokens) => { Commands::External(tokens) => {
wrap_and_run_command( wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run)?;
secrets_provider,
&config,
&provider_config,
tokens,
cli.profile,
cli.dry_run,
)?;
} }
Commands::Completions { shell } => { Commands::Completions { shell } => {
let mut cmd = Cli::command(); let mut cmd = Cli::command();
+23 -31
View File
@@ -25,7 +25,7 @@ use anyhow::{Context, Result};
use log::debug; use log::debug;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::serde_as; use serde_with::serde_as;
use serde_with::{DisplayFromStr, skip_serializing_none}; use serde_with::skip_serializing_none;
use std::borrow::Cow; use std::borrow::Cow;
use std::path::PathBuf; use std::path::PathBuf;
use std::{env, fs}; use std::{env, fs};
@@ -97,8 +97,6 @@ fn flags_or_files(run_config: &RunConfig) -> Result<(), ValidationError> {
} }
} }
#[serde_as]
#[skip_serializing_none]
/// Configuration for a secret provider. /// Configuration for a secret provider.
/// ///
/// Example: create a local provider config and validate it /// Example: create a local provider config and validate it
@@ -108,37 +106,27 @@ fn flags_or_files(run_config: &RunConfig) -> Result<(), ValidationError> {
/// use gman::providers::local::LocalProvider; /// use gman::providers::local::LocalProvider;
/// use validator::Validate; /// use validator::Validate;
/// ///
/// let provider_type = SupportedProvider::Local(LocalProvider); /// let provider_type = SupportedProvider::Local { provider_def: LocalProvider::default() };
/// let provider_config = ProviderConfig { provider_type, ..Default::default() }; /// let provider_config = ProviderConfig { provider_type, ..Default::default() };
/// provider_config.validate().unwrap(); /// provider_config.validate().unwrap();
/// ``` /// ```
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
#[skip_serializing_none]
pub struct ProviderConfig { pub struct ProviderConfig {
#[validate(required)] #[validate(required)]
pub name: Option<String>, pub name: Option<String>,
#[serde_as(as = "DisplayFromStr")] #[serde(flatten, rename = "type")]
#[serde(rename = "type")] #[validate(nested)]
pub provider_type: SupportedProvider, pub provider_type: SupportedProvider,
pub password_file: Option<PathBuf>,
pub git_branch: Option<String>,
pub git_remote_url: Option<String>,
pub git_user_name: Option<String>,
#[validate(email)]
pub git_user_email: Option<String>,
pub git_executable: Option<PathBuf>,
} }
impl Default for ProviderConfig { impl Default for ProviderConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
name: Some("local".into()), name: Some("local".into()),
provider_type: SupportedProvider::Local(LocalProvider), provider_type: SupportedProvider::Local {
password_file: Config::local_provider_password_file(), provider_def: LocalProvider::default(),
git_branch: Some("main".into()), },
git_remote_url: None,
git_user_name: None,
git_user_email: None,
git_executable: None,
} }
} }
} }
@@ -151,11 +139,11 @@ impl ProviderConfig {
/// let provider_config = ProviderConfig::default().extract_provider(); /// let provider_config = ProviderConfig::default().extract_provider();
/// println!("using provider: {}", provider_config.name()); /// println!("using provider: {}", provider_config.name());
/// ``` /// ```
pub fn extract_provider(&self) -> Box<dyn SecretProvider> { pub fn extract_provider(&mut self) -> &mut dyn SecretProvider {
match &self.provider_type { match &mut self.provider_type {
SupportedProvider::Local(p) => { SupportedProvider::Local { provider_def } => {
debug!("Using local secret provider"); debug!("Using local secret provider");
Box::new(*p) provider_def
} }
} }
} }
@@ -173,7 +161,7 @@ impl ProviderConfig {
/// use gman::providers::local::LocalProvider; /// use gman::providers::local::LocalProvider;
/// use validator::Validate; /// use validator::Validate;
/// ///
/// let provider_type = SupportedProvider::Local(LocalProvider); /// let provider_type = SupportedProvider::Local{ provider_def: LocalProvider::default() };
/// let provider_config = ProviderConfig { provider_type, ..Default::default() }; /// let provider_config = ProviderConfig { provider_type, ..Default::default() };
/// let cfg = Config{ providers: vec![provider_config], ..Default::default() }; /// let cfg = Config{ providers: vec![provider_config], ..Default::default() };
/// cfg.validate().unwrap(); /// cfg.validate().unwrap();
@@ -289,12 +277,16 @@ pub fn load_config() -> Result<Config> {
config config
.providers .providers
.iter_mut() .iter_mut()
.filter(|p| matches!(p.provider_type, SupportedProvider::Local(_))) .filter(|p| matches!(p.provider_type, SupportedProvider::Local { .. }))
.for_each(|p| { .for_each(|p| match p.provider_type {
if p.password_file.is_none() SupportedProvider::Local {
&& let Some(local_password_file) = Config::local_provider_password_file() ref mut provider_def,
{ } => {
p.password_file = Some(local_password_file); if provider_def.password_file.is_none()
&& let Some(local_password_file) = Config::local_provider_password_file()
{
provider_def.password_file = Some(local_password_file);
}
} }
}); });
+97 -88
View File
@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
use std::{env, fs}; use std::{env, fs};
use zeroize::Zeroize; use zeroize::Zeroize;
use crate::config::ProviderConfig; use crate::config::Config;
use crate::providers::SecretProvider; use crate::providers::SecretProvider;
use crate::providers::git_sync::{SyncOpts, repo_name_from_url, sync_and_push}; use crate::providers::git_sync::{SyncOpts, repo_name_from_url, sync_and_push};
use crate::{ use crate::{
@@ -21,27 +21,12 @@ use chacha20poly1305::{
}; };
use dialoguer::{Input, theme}; use dialoguer::{Input, theme};
use log::{debug, error}; use log::{debug, error};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use theme::ColorfulTheme; use theme::ColorfulTheme;
use validator::Validate; use validator::Validate;
/// Configuration for the local file-based provider. #[skip_serializing_none]
#[derive(Debug, Clone)]
pub struct LocalProviderConfig {
pub vault_path: String,
}
impl Default for LocalProviderConfig {
fn default() -> Self {
Self {
vault_path: dirs::home_dir()
.map(|p| p.join(".gman_vault"))
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| ".gman_vault".into()),
}
}
}
/// File-based vault provider with optional Git sync. /// File-based vault provider with optional Git sync.
/// ///
/// This provider stores encrypted envelopes in a per-user configuration /// This provider stores encrypted envelopes in a per-user configuration
@@ -54,37 +39,59 @@ impl Default for LocalProviderConfig {
/// use gman::providers::SecretProvider; /// use gman::providers::SecretProvider;
/// use gman::config::Config; /// use gman::config::Config;
/// ///
/// let provider = LocalProvider; /// let provider = LocalProvider::default();
/// let cfg = Config::default(); /// let cfg = Config::default();
/// // Will prompt for a password when reading/writing secrets unless a /// // Will prompt for a password when reading/writing secrets unless a
/// // password file is configured. /// // password file is configured.
/// // provider.set_secret(&cfg, "MY_SECRET", "value")?; /// // provider.set_secret(&cfg, "MY_SECRET", "value")?;
/// # Ok::<(), anyhow::Error>(()) /// # Ok::<(), anyhow::Error>(())
/// ``` /// ```
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
pub struct LocalProvider; #[serde(deny_unknown_fields)]
pub struct LocalProvider {
pub password_file: Option<PathBuf>,
pub git_branch: Option<String>,
pub git_remote_url: Option<String>,
pub git_user_name: Option<String>,
#[validate(email)]
pub git_user_email: Option<String>,
pub git_executable: Option<PathBuf>,
}
impl Default for LocalProvider {
fn default() -> Self {
Self {
password_file: Config::local_provider_password_file(),
git_branch: Some("main".into()),
git_remote_url: None,
git_user_name: None,
git_user_email: None,
git_executable: None,
}
}
}
impl SecretProvider for LocalProvider { impl SecretProvider for LocalProvider {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"LocalProvider" "LocalProvider"
} }
fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result<String> { fn get_secret(&self, key: &str) -> Result<String> {
let vault_path = active_vault_path(config)?; let vault_path = self.active_vault_path()?;
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let envelope = vault let envelope = vault
.get(key) .get(key)
.with_context(|| format!("key '{key}' not found in the vault"))?; .with_context(|| format!("key '{key}' not found in the vault"))?;
let password = get_password(config)?; let password = self.get_password()?;
let plaintext = decrypt_string(&password, envelope)?; let plaintext = decrypt_string(&password, envelope)?;
drop(password); drop(password);
Ok(plaintext) Ok(plaintext)
} }
fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> { fn set_secret(&self, key: &str, value: &str) -> Result<()> {
let vault_path = active_vault_path(config)?; let vault_path = self.active_vault_path()?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if vault.contains_key(key) { if vault.contains_key(key) {
error!( error!(
@@ -93,7 +100,7 @@ impl SecretProvider for LocalProvider {
bail!("key '{key}' already exists"); bail!("key '{key}' already exists");
} }
let password = get_password(config)?; let password = self.get_password()?;
let envelope = encrypt_string(&password, value)?; let envelope = encrypt_string(&password, value)?;
drop(password); drop(password);
@@ -102,11 +109,11 @@ impl SecretProvider for LocalProvider {
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
} }
fn update_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> { fn update_secret(&self, key: &str, value: &str) -> Result<()> {
let vault_path = active_vault_path(config)?; let vault_path = self.active_vault_path()?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let password = get_password(config)?; let password = self.get_password()?;
let envelope = encrypt_string(&password, value)?; let envelope = encrypt_string(&password, value)?;
drop(password); drop(password);
@@ -125,8 +132,8 @@ impl SecretProvider for LocalProvider {
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
} }
fn delete_secret(&self, config: &ProviderConfig, key: &str) -> Result<()> { fn delete_secret(&self, key: &str) -> Result<()> {
let vault_path = active_vault_path(config)?; let vault_path = self.active_vault_path()?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if !vault.contains_key(key) { if !vault.contains_key(key) {
error!("Key '{key}' does not exist in the vault."); error!("Key '{key}' does not exist in the vault.");
@@ -137,18 +144,18 @@ impl SecretProvider for LocalProvider {
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
} }
fn list_secrets(&self, config: &ProviderConfig) -> Result<Vec<String>> { fn list_secrets(&self) -> Result<Vec<String>> {
let vault_path = active_vault_path(config)?; let vault_path = self.active_vault_path()?;
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let keys: Vec<String> = vault.keys().cloned().collect(); let keys: Vec<String> = vault.keys().cloned().collect();
Ok(keys) Ok(keys)
} }
fn sync(&self, config: &mut ProviderConfig) -> Result<()> { fn sync(&mut self) -> Result<()> {
let mut config_changed = false; let mut config_changed = false;
if config.git_branch.is_none() { if self.git_branch.is_none() {
config_changed = true; config_changed = true;
debug!("Prompting user to set git_branch in config for sync"); debug!("Prompting user to set git_branch in config for sync");
let branch: String = Input::with_theme(&ColorfulTheme::default()) let branch: String = Input::with_theme(&ColorfulTheme::default())
@@ -156,18 +163,18 @@ impl SecretProvider for LocalProvider {
.default("main".into()) .default("main".into())
.interact_text()?; .interact_text()?;
config.git_branch = Some(branch); self.git_branch = Some(branch);
} }
if config.git_remote_url.is_none() { if self.git_remote_url.is_none() {
config_changed = true; config_changed = true;
debug!("Prompting user to set git_remote in config for sync"); debug!("Prompting user to set git_remote in config for sync");
let remote: String = Input::with_theme(&ColorfulTheme::default()) let remote: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter remote git URL to sync with") .with_prompt("Enter remote git URL to sync with")
.validate_with(|s: &String| { .validate_with(|s: &String| {
ProviderConfig { LocalProvider {
git_remote_url: Some(s.clone()), git_remote_url: Some(s.clone()),
..ProviderConfig::default() ..LocalProvider::default()
} }
.validate() .validate()
.map(|_| ()) .map(|_| ())
@@ -175,27 +182,66 @@ impl SecretProvider for LocalProvider {
}) })
.interact_text()?; .interact_text()?;
config.git_remote_url = Some(remote); self.git_remote_url = Some(remote);
} }
if config_changed { if config_changed {
debug!("Saving updated config"); debug!("Saving updated config");
confy::store("gman", "config", &config) confy::store("gman", "config", &self)
.with_context(|| "failed to save updated config")?; .with_context(|| "failed to save updated config")?;
} }
let sync_opts = SyncOpts { let sync_opts = SyncOpts {
remote_url: &config.git_remote_url, remote_url: &self.git_remote_url,
branch: &config.git_branch, branch: &self.git_branch,
user_name: &config.git_user_name, user_name: &self.git_user_name,
user_email: &config.git_user_email, user_email: &self.git_user_email,
git_executable: &config.git_executable, git_executable: &self.git_executable,
}; };
sync_and_push(&sync_opts) sync_and_push(&sync_opts)
} }
} }
impl LocalProvider {
fn repo_dir_for_config(&self) -> Result<Option<PathBuf>> {
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<PathBuf> {
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<SecretString> {
if let Some(password_file) = &self.password_file {
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<PathBuf> { fn default_vault_path() -> Result<PathBuf> {
let xdg_path = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from); let xdg_path = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from);
@@ -213,26 +259,6 @@ fn base_config_dir() -> Result<PathBuf> {
.ok_or_else(|| anyhow!("Failed to determine config dir")) .ok_or_else(|| anyhow!("Failed to determine config dir"))
} }
fn repo_dir_for_config(config: &ProviderConfig) -> Result<Option<PathBuf>> {
if let Some(remote) = &config.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(config: &ProviderConfig) -> Result<PathBuf> {
if let Some(dir) = repo_dir_for_config(config)?
&& dir.exists()
{
return Ok(dir.join("vault.yml"));
}
default_vault_path()
}
fn load_vault(path: &Path) -> Result<HashMap<String, String>> { fn load_vault(path: &Path) -> Result<HashMap<String, String>> {
if !path.exists() { if !path.exists() {
return Ok(HashMap::new()); return Ok(HashMap::new());
@@ -394,23 +420,6 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> {
Ok(s) Ok(s)
} }
fn get_password(config: &ProviderConfig) -> Result<SecretString> {
if let Some(password_file) = &config.password_file {
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()))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -448,11 +457,11 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let file = dir.path().join("pw.txt"); let file = dir.path().join("pw.txt");
fs::write(&file, "secretpw\n").unwrap(); fs::write(&file, "secretpw\n").unwrap();
let cfg = ProviderConfig { let provider = LocalProvider {
password_file: Some(file), password_file: Some(file),
..ProviderConfig::default() ..LocalProvider::default()
}; };
let pw = get_password(&cfg).unwrap(); let pw = provider.get_password().unwrap();
assert_eq!(pw.expose_secret(), "secretpw"); assert_eq!(pw.expose_secret(), "secretpw");
} }
} }
+26 -32
View File
@@ -2,46 +2,36 @@
//! //!
//! Implementations provide storage/backends for secrets and a common //! Implementations provide storage/backends for secrets and a common
//! interface used by the CLI. //! interface used by the CLI.
//!
//! Selecting a provider from a string:
//! ```
//! use std::str::FromStr;
//! use gman::providers::SupportedProvider;
//!
//! let p = SupportedProvider::from_str("local").unwrap();
//! assert_eq!(p.to_string(), "local");
//! ```
mod git_sync; mod git_sync;
pub mod local; pub mod local;
use crate::config::ProviderConfig;
use crate::providers::local::LocalProvider; use crate::providers::local::LocalProvider;
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::str::FromStr;
use thiserror::Error; use thiserror::Error;
use validator::{Validate, ValidationErrors};
/// A secret storage backend capable of CRUD and sync, with optional /// A secret storage backend capable of CRUD and sync, with optional
/// update and listing /// update and listing
pub trait SecretProvider { pub trait SecretProvider {
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result<String>; fn get_secret(&self, key: &str) -> Result<String>;
fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()>; fn set_secret(&self, key: &str, value: &str) -> Result<()>;
fn update_secret(&self, _config: &ProviderConfig, _key: &str, _value: &str) -> Result<()> { fn update_secret(&self, _key: &str, _value: &str) -> Result<()> {
Err(anyhow!( Err(anyhow!(
"update secret not supported for provider {}", "update secret not supported for provider {}",
self.name() self.name()
)) ))
} }
fn delete_secret(&self, config: &ProviderConfig, key: &str) -> Result<()>; fn delete_secret(&self, key: &str) -> Result<()>;
fn list_secrets(&self, _config: &ProviderConfig) -> Result<Vec<String>> { fn list_secrets(&self) -> Result<Vec<String>> {
Err(anyhow!( Err(anyhow!(
"list secrets is not supported for the provider {}", "list secrets is not supported for the provider {}",
self.name() self.name()
)) ))
} }
fn sync(&self, config: &mut ProviderConfig) -> Result<()>; fn sync(&mut self) -> Result<()>;
} }
/// Errors when parsing a provider identifier. /// Errors when parsing a provider identifier.
@@ -52,24 +42,28 @@ pub enum ParseProviderError {
} }
/// Registry of built-in providers. /// Registry of built-in providers.
#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] #[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
//TODO test that this works with the AWS config
pub enum SupportedProvider { pub enum SupportedProvider {
Local(LocalProvider), Local {
#[serde(flatten)]
provider_def: LocalProvider,
},
}
impl Validate for SupportedProvider {
fn validate(&self) -> Result<(), ValidationErrors> {
match self {
SupportedProvider::Local { provider_def } => provider_def.validate(),
}
}
} }
impl Default for SupportedProvider { impl Default for SupportedProvider {
fn default() -> Self { fn default() -> Self {
SupportedProvider::Local(LocalProvider) SupportedProvider::Local {
} provider_def: LocalProvider::default(),
}
impl FromStr for SupportedProvider {
type Err = ParseProviderError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_lowercase().as_str() {
"local" => Ok(SupportedProvider::Local(LocalProvider)),
_ => Err(ParseProviderError::Unsupported(s.to_string())),
} }
} }
} }
@@ -77,7 +71,7 @@ impl FromStr for SupportedProvider {
impl Display for SupportedProvider { impl Display for SupportedProvider {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self { match self {
SupportedProvider::Local(_) => write!(f, "local"), SupportedProvider::Local { .. } => write!(f, "local"),
} }
} }
} }
-56
View File
@@ -1,8 +1,6 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use gman::config::{Config, ProviderConfig, RunConfig}; use gman::config::{Config, ProviderConfig, RunConfig};
use gman::providers::SupportedProvider;
use gman::providers::local::LocalProvider;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use validator::Validate; use validator::Validate;
@@ -161,65 +159,11 @@ mod tests {
assert!(run_config.validate().is_err()); assert!(run_config.validate().is_err());
} }
#[test]
fn test_provider_config_valid() {
let config = ProviderConfig {
name: Some("local-test".to_string()),
provider_type: SupportedProvider::Local(LocalProvider),
password_file: None,
git_branch: None,
git_remote_url: None,
git_user_name: None,
git_user_email: Some("test@example.com".to_string()),
git_executable: None,
};
assert!(config.validate().is_ok());
}
#[test]
fn test_provider_config_invalid_email() {
let config = ProviderConfig {
name: Some("local-test".to_string()),
provider_type: SupportedProvider::Local(LocalProvider),
password_file: None,
git_branch: None,
git_remote_url: None,
git_user_name: None,
git_user_email: Some("test".to_string()),
git_executable: None,
};
assert!(config.validate().is_err());
}
#[test]
fn test_provider_config_missing_name() {
let config = ProviderConfig {
name: None,
provider_type: SupportedProvider::Local(LocalProvider),
password_file: None,
git_branch: None,
git_remote_url: None,
git_user_name: None,
git_user_email: None,
git_executable: None,
};
assert!(config.validate().is_err());
}
#[test] #[test]
fn test_provider_config_default() { fn test_provider_config_default() {
let config = ProviderConfig::default(); let config = ProviderConfig::default();
assert_eq!(config.name, Some("local".to_string())); assert_eq!(config.name, Some("local".to_string()));
assert_eq!(config.git_user_email, None);
assert_eq!(config.password_file, Config::local_provider_password_file());
assert_eq!(config.git_branch, Some("main".into()));
assert_eq!(config.git_remote_url, None);
assert_eq!(config.git_user_name, None);
assert_eq!(config.git_executable, None);
} }
#[test] #[test]
+47 -12
View File
@@ -1,21 +1,56 @@
use gman::providers::local::LocalProviderConfig; use gman::config::Config;
use gman::providers::local::LocalProvider;
use pretty_assertions::assert_eq;
use pretty_assertions::assert_str_eq; use pretty_assertions::assert_str_eq;
use validator::Validate;
#[test]
fn test_local_provider_config_default() {
let config = LocalProviderConfig::default();
let expected_path = dirs::home_dir()
.map(|p| p.join(".gman_vault"))
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| ".gman_vault".into());
assert_str_eq!(config.vault_path, expected_path);
}
#[test] #[test]
fn test_local_provider_name() { fn test_local_provider_name() {
use gman::providers::SecretProvider; use gman::providers::SecretProvider;
use gman::providers::local::LocalProvider; use gman::providers::local::LocalProvider;
let provider = LocalProvider; let provider = LocalProvider::default();
assert_str_eq!(provider.name(), "LocalProvider"); assert_str_eq!(provider.name(), "LocalProvider");
} }
#[test]
fn test_local_provider_valid() {
let provider = LocalProvider {
password_file: None,
git_branch: None,
git_remote_url: None,
git_user_name: None,
git_user_email: Some("test@example.com".to_string()),
git_executable: None,
};
assert!(provider.validate().is_ok());
}
#[test]
fn test_local_provider_invalid_email() {
let config = LocalProvider {
password_file: None,
git_branch: None,
git_remote_url: None,
git_user_name: None,
git_user_email: Some("test".to_string()),
git_executable: None,
};
assert!(config.validate().is_err());
}
#[test]
fn test_local_provider_default() {
let provider = LocalProvider::default();
assert_eq!(
provider.password_file,
Config::local_provider_password_file()
);
assert_eq!(provider.git_branch, Some("main".into()));
assert_eq!(provider.git_remote_url, None);
assert_eq!(provider.git_user_name, None);
assert_eq!(provider.git_user_email, None);
assert_eq!(provider.git_executable, None);
}
+14 -43
View File
@@ -1,49 +1,20 @@
use gman::providers::local::LocalProvider; use gman::config::ProviderConfig;
use gman::providers::{ParseProviderError, SupportedProvider}; use gman::providers::ParseProviderError;
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::assert_eq;
use std::str::FromStr; use validator::Validate;
#[test]
fn test_supported_provider_from_str() {
assert_eq!(
SupportedProvider::from_str("local").unwrap(),
SupportedProvider::Local(LocalProvider)
);
assert_eq!(
SupportedProvider::from_str(" Local ").unwrap(),
SupportedProvider::Local(LocalProvider)
);
assert!(matches!(
SupportedProvider::from_str("invalid"),
Err(ParseProviderError::Unsupported(_))
));
}
#[test]
fn test_supported_provider_display() {
assert_str_eq!(SupportedProvider::Local(LocalProvider).to_string(), "local");
}
#[test]
fn test_supported_provider_from_str_valid() {
assert_eq!(
SupportedProvider::from_str("local").unwrap(),
SupportedProvider::Local(LocalProvider)
);
assert_eq!(
SupportedProvider::from_str("LOCAL").unwrap(),
SupportedProvider::Local(LocalProvider)
);
}
#[test]
fn test_supported_provider_from_str_invalid() {
let err = SupportedProvider::from_str("invalid").unwrap_err();
assert_str_eq!(err.to_string(), "unsupported provider 'invalid'");
}
#[test] #[test]
fn test_parse_provider_error_display() { fn test_parse_provider_error_display() {
let err = ParseProviderError::Unsupported("test".to_string()); let err = ParseProviderError::Unsupported("test".to_string());
assert_eq!(err.to_string(), "unsupported provider 'test'"); assert_eq!(err.to_string(), "unsupported provider 'test'");
} }
#[test]
fn test_provider_config_missing_name() {
let config = ProviderConfig {
name: None,
..Default::default()
};
assert!(config.validate().is_err());
}