12 Commits

19 changed files with 298 additions and 615 deletions
+12
View File
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v0.2.3 (2025-10-14)
### Refactor
- Refactored the library for gman so that it dynamically names config and password files to be used across any application
## v0.2.2 (2025-09-30)
### Refactor
- Environment variable interpolation in config file works globally, not based on type
## v0.2.1 (2025-09-30) ## v0.2.1 (2025-09-30)
### Feat ### Feat
+1 -1
View File
@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at reported to the community leaders responsible for enforcement at
d4udts@gmail.com. alex.j.tusa@gmail.com.
All complaints will be reviewed and investigated promptly and fairly. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the All community leaders are obligated to respect the privacy and security of the
Generated
+188 -207
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gman" name = "gman"
version = "0.2.1" version = "0.2.3"
edition = "2024" edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"] authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "Universal command line secret management and injection tool" description = "Universal command line secret management and injection tool"
+1 -3
View File
@@ -89,6 +89,7 @@ gman aws sts get-caller-identity
- [Features](#features) - [Features](#features)
- [Installation](#installation) - [Installation](#installation)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Environment Variable Interpolation](#environment-variable-interpolation)
- [Providers](#providers) - [Providers](#providers)
- [Local](#provider-local) - [Local](#provider-local)
- [AWS Secrets Manager](#provider-aws_secrets_manager) - [AWS Secrets Manager](#provider-aws_secrets_manager)
@@ -264,9 +265,6 @@ providers:
aws_region: us-east-1 aws_region: us-east-1
``` ```
**Important Note:** Environment variable interpolation is only supported in string or numeric fields. It is not
supported in lists or maps.
## Providers ## Providers
`gman` supports multiple providers for secret storage. The default provider is `local`, which stores secrets in an `gman` supports multiple providers for secret storage. The default provider is `local`, which stores secrets in an
encrypted file on your filesystem. The CLI and config format are designed to be extensible so new providers can be encrypted file on your filesystem. The CLI and config format are designed to be extensible so new providers can be
+13 -9
View File
@@ -257,7 +257,7 @@ pub fn parse_args(
pub fn run_config_completer(current: &OsStr) -> Vec<CompletionCandidate> { pub fn run_config_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy(); let cur = current.to_string_lossy();
match load_config() { match load_config(true) {
Ok(config) => { Ok(config) => {
if let Some(run_configs) = config.run_configs { if let Some(run_configs) = config.run_configs {
run_configs run_configs
@@ -282,7 +282,7 @@ pub fn run_config_completer(current: &OsStr) -> Vec<CompletionCandidate> {
pub fn provider_completer(current: &OsStr) -> Vec<CompletionCandidate> { pub fn provider_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy(); let cur = current.to_string_lossy();
match load_config() { match load_config(true) {
Ok(config) => config Ok(config) => config
.providers .providers
.iter() .iter()
@@ -300,7 +300,7 @@ pub fn provider_completer(current: &OsStr) -> Vec<CompletionCandidate> {
pub fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> { pub fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy(); let cur = current.to_string_lossy();
match load_config() { match load_config(true) {
Ok(config) => { Ok(config) => {
let mut provider_config = match config.extract_provider_config(None) { let mut provider_config = match config.extract_provider_config(None) {
Ok(pc) => pc, Ok(pc) => pc,
@@ -323,6 +323,7 @@ pub fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
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::get_config_file_path;
use gman::config::{Config, RunConfig}; use gman::config::{Config, RunConfig};
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use serial_test::serial; use serial_test::serial;
@@ -436,9 +437,10 @@ mod tests {
fn test_run_config_completer_filters_by_prefix() { fn test_run_config_completer_filters_by_prefix() {
let td = tempdir().unwrap(); let td = tempdir().unwrap();
let xdg = td.path().join("xdg"); let xdg = td.path().join("xdg");
let app_dir = xdg.join("gman");
fs::create_dir_all(&app_dir).unwrap();
unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg) }; unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg) };
let cfg_path = get_config_file_path().unwrap();
let app_dir = cfg_path.parent().unwrap().to_path_buf();
fs::create_dir_all(&app_dir).unwrap();
let yaml = indoc::indoc! { let yaml = indoc::indoc! {
"--- "---
@@ -471,9 +473,10 @@ mod tests {
fn test_provider_completer_lists_matching_providers() { fn test_provider_completer_lists_matching_providers() {
let td = tempdir().unwrap(); let td = tempdir().unwrap();
let xdg = td.path().join("xdg"); let xdg = td.path().join("xdg");
let app_dir = xdg.join("gman");
fs::create_dir_all(&app_dir).unwrap();
unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg) }; unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg) };
let cfg_path = get_config_file_path().unwrap();
let app_dir = cfg_path.parent().unwrap().to_path_buf();
fs::create_dir_all(&app_dir).unwrap();
let yaml = indoc::indoc! { let yaml = indoc::indoc! {
"--- "---
@@ -508,9 +511,10 @@ mod tests {
async fn test_secrets_completer_filters_keys_by_prefix() { async fn test_secrets_completer_filters_keys_by_prefix() {
let td = tempdir().unwrap(); let td = tempdir().unwrap();
let xdg = td.path().join("xdg"); let xdg = td.path().join("xdg");
let app_dir = xdg.join("gman");
fs::create_dir_all(&app_dir).unwrap();
unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg) }; unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg) };
let cfg_path = get_config_file_path().unwrap();
let app_dir = cfg_path.parent().unwrap().to_path_buf();
fs::create_dir_all(&app_dir).unwrap();
let yaml = indoc::indoc! { let yaml = indoc::indoc! {
"--- "---
+3 -14
View File
@@ -123,13 +123,6 @@ enum Commands {
/// configured in a corresponding run profile /// configured in a corresponding run profile
#[command(external_subcommand)] #[command(external_subcommand)]
External(Vec<OsString>), External(Vec<OsString>),
/// Generate shell completion scripts
Completions {
/// The shell to generate the script for
#[arg(value_enum)]
shell: clap_complete::Shell,
},
} }
#[tokio::main] #[tokio::main]
@@ -157,7 +150,7 @@ async fn main() -> Result<()> {
exit(1); exit(1);
} }
let config = load_config()?; let config = load_config(true)?;
let mut provider_config = config.extract_provider_config(cli.provider.clone())?; let mut provider_config = config.extract_provider_config(cli.provider.clone())?;
let secrets_provider = provider_config.extract_provider(); let secrets_provider = provider_config.extract_provider();
@@ -238,7 +231,8 @@ async fn main() -> Result<()> {
} }
} }
Commands::Config {} => { Commands::Config {} => {
let config_yaml = serde_yaml::to_string(&config) let uninterpolated_config = load_config(false)?;
let config_yaml = serde_yaml::to_string(&uninterpolated_config)
.with_context(|| "failed to serialize existing configuration")?; .with_context(|| "failed to serialize existing configuration")?;
let new_config = Editor::new() let new_config = Editor::new()
.edit(&config_yaml) .edit(&config_yaml)
@@ -267,11 +261,6 @@ async fn main() -> Result<()> {
Commands::External(tokens) => { Commands::External(tokens) => {
wrap_and_run_command(cli.provider, &config, tokens, cli.profile, cli.dry_run).await?; wrap_and_run_command(cli.provider, &config, tokens, cli.profile, cli.dry_run).await?;
} }
Commands::Completions { shell } => {
let mut cmd = Cli::command();
let bin_name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, bin_name, &mut io::stdout());
}
} }
Ok(()) Ok(())
+2 -2
View File
@@ -46,7 +46,7 @@ pub fn init_logging_config() -> log4rs::Config {
pub fn get_log_path() -> PathBuf { pub fn get_log_path() -> PathBuf {
let base_dir = dirs::cache_dir().unwrap_or_else(env::temp_dir); let base_dir = dirs::cache_dir().unwrap_or_else(env::temp_dir);
let log_dir = base_dir.join("gman"); let log_dir = base_dir.join(env!("CARGO_CRATE_NAME"));
let dir = if let Err(e) = fs::create_dir_all(&log_dir) { let dir = if let Err(e) = fs::create_dir_all(&log_dir) {
eprintln!( eprintln!(
@@ -77,7 +77,7 @@ pub fn persist_config_file(config: &Config) -> Result<()> {
fs::write(&config_path, s) fs::write(&config_path, s)
.with_context(|| format!("failed to write {}", config_path.display()))?; .with_context(|| format!("failed to write {}", config_path.display()))?;
} else { } else {
confy::store("gman", "config", config) confy::store(env!("CARGO_CRATE_NAME"), "config", config)
.with_context(|| "failed to save updated config via confy")?; .with_context(|| "failed to save updated config via confy")?;
} }
+38 -326
View File
@@ -21,6 +21,7 @@
//! rc.validate().unwrap(); //! rc.validate().unwrap();
//! ``` //! ```
use crate::calling_app_name;
use crate::providers::local::LocalProvider; use crate::providers::local::LocalProvider;
use crate::providers::{SecretProvider, SupportedProvider}; use crate::providers::{SecretProvider, SupportedProvider};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -46,19 +47,14 @@ use validator::{Validate, ValidationError};
#[validate(schema(function = "flags_or_files"))] #[validate(schema(function = "flags_or_files"))]
pub struct RunConfig { pub struct RunConfig {
#[validate(required)] #[validate(required)]
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub name: Option<String>, pub name: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub provider: Option<String>, pub provider: Option<String>,
#[validate(required)] #[validate(required)]
pub secrets: Option<Vec<String>>, pub secrets: Option<Vec<String>>,
pub files: Option<Vec<PathBuf>>, pub files: Option<Vec<PathBuf>>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub flag: Option<String>, pub flag: Option<String>,
#[validate(range(min = 1))] #[validate(range(min = 1))]
#[serde(default, deserialize_with = "deserialize_optional_usize_env_var")]
pub flag_position: Option<usize>, pub flag_position: Option<usize>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub arg_format: Option<String>, pub arg_format: Option<String>,
} }
@@ -198,7 +194,6 @@ impl ProviderConfig {
#[validate(schema(function = "default_provider_exists"))] #[validate(schema(function = "default_provider_exists"))]
#[validate(schema(function = "providers_names_are_unique"))] #[validate(schema(function = "providers_names_are_unique"))]
pub struct Config { pub struct Config {
#[serde(deserialize_with = "deserialize_optional_env_var")]
pub default_provider: Option<String>, pub default_provider: Option<String>,
#[validate(length(min = 1))] #[validate(length(min = 1))]
#[validate(nested)] #[validate(nested)]
@@ -274,48 +269,49 @@ impl Config {
/// Discover the default password file for the local provider. /// Discover the default password file for the local provider.
/// ///
/// On most systems this resolves to `~/.gman_password` when the file /// On most systems this resolves to `~/.<executable_name>_password`
/// exists, otherwise `None`. pub fn local_provider_password_file() -> PathBuf {
pub fn local_provider_password_file() -> Option<PathBuf> { dirs::home_dir()
let candidate = dirs::home_dir().map(|p| p.join(".gman_password")); .map(|p| p.join(format!(".{}_password", calling_app_name())))
match candidate { .expect("unable to determine home directory for local provider password file")
Some(p) if p.exists() => Some(p),
_ => None,
}
} }
} }
/// Load and validate the application configuration. /// Load and validate the application configuration.
/// ///
/// This uses the `confy` crate to load the configuration from a file /// This uses the `confy` crate to load the configuration from a file
/// (e.g. `~/.config/gman/config.yaml`). If the file does /// (e.g. `~/.config/<executable_name>/config.yaml`). If the file does
/// not exist, a default configuration is created and saved. /// not exist, a default configuration is created and saved.
/// ///
/// ```no_run /// ```no_run
/// # use gman::config::load_config; /// # use gman::config::load_config;
/// let config = load_config().unwrap(); /// // Load config with environment variable interpolation enabled
/// let config = load_config(true).unwrap();
/// println!("loaded config: {:?}", config); /// println!("loaded config: {:?}", config);
/// ``` /// ```
pub fn load_config() -> Result<Config> { pub fn load_config(interpolate: bool) -> Result<Config> {
let xdg_path = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from); let xdg_path = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from);
let mut config: Config = if let Some(base) = xdg_path.as_ref() { let mut config: Config = if let Some(base) = xdg_path.as_ref() {
let app_dir = base.join("gman"); let app_dir = base.join(calling_app_name());
let yml = app_dir.join("config.yml"); let yml = app_dir.join("config.yml");
let yaml = app_dir.join("config.yaml"); let yaml = app_dir.join("config.yaml");
if yml.exists() || yaml.exists() { if yml.exists() || yaml.exists() {
let load_path = if yml.exists() { &yml } else { &yaml }; let load_path = if yml.exists() { &yml } else { &yaml };
let content = fs::read_to_string(load_path) let mut content = fs::read_to_string(load_path)
.with_context(|| format!("failed to read config file '{}'", load_path.display()))?; .with_context(|| format!("failed to read config file '{}'", load_path.display()))?;
if interpolate {
content = interpolate_env_vars(&content);
}
let cfg: Config = serde_yaml::from_str(&content).with_context(|| { let cfg: Config = serde_yaml::from_str(&content).with_context(|| {
format!("failed to parse YAML config at '{}'", load_path.display()) format!("failed to parse YAML config at '{}'", load_path.display())
})?; })?;
cfg cfg
} else { } else {
confy::load("gman", "config")? load_confy_config(interpolate)?
} }
} else { } else {
confy::load("gman", "config")? load_confy_config(interpolate)?
}; };
config.validate()?; config.validate()?;
@@ -329,19 +325,32 @@ pub fn load_config() -> Result<Config> {
ref mut provider_def, ref mut provider_def,
} = p.provider_type } = p.provider_type
&& provider_def.password_file.is_none() && provider_def.password_file.is_none()
&& let Some(local_password_file) = Config::local_provider_password_file() && Config::local_provider_password_file().exists()
{ {
provider_def.password_file = Some(local_password_file); provider_def.password_file = Some(Config::local_provider_password_file());
} }
}); });
Ok(config) Ok(config)
} }
fn load_confy_config(interpolate: bool) -> Result<Config> {
let load_path = confy::get_configuration_file_path(&calling_app_name(), "config")?;
let mut content = fs::read_to_string(&load_path)
.with_context(|| format!("failed to read config file '{}'", load_path.display()))?;
if interpolate {
content = interpolate_env_vars(&content);
}
let cfg: Config = serde_yaml::from_str(&content)
.with_context(|| format!("failed to parse YAML config at '{}'", load_path.display()))?;
Ok(cfg)
}
/// Returns the configuration file path that `confy` will use /// Returns the configuration file path that `confy` will use
pub fn get_config_file_path() -> Result<PathBuf> { pub fn get_config_file_path() -> Result<PathBuf> {
if let Some(base) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { if let Some(base) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) {
let dir = base.join("gman"); let dir = base.join(calling_app_name());
let yml = dir.join("config.yml"); let yml = dir.join("config.yml");
let yaml = dir.join("config.yaml"); let yaml = dir.join("config.yaml");
if yml.exists() || yaml.exists() { if yml.exists() || yaml.exists() {
@@ -349,54 +358,10 @@ pub fn get_config_file_path() -> Result<PathBuf> {
} }
return Ok(dir.join("config.yml")); return Ok(dir.join("config.yml"));
} }
Ok(confy::get_configuration_file_path("gman", "config")?) Ok(confy::get_configuration_file_path(
} &calling_app_name(),
"config",
pub fn deserialize_optional_env_var<'de, D>(deserializer: D) -> Result<Option<String>, D::Error> )?)
where
D: serde::Deserializer<'de>,
{
let s: Option<String> = Option::deserialize(deserializer)?;
match s {
Some(value) => {
let interpolated = interpolate_env_vars(&value);
Ok(Some(interpolated))
}
None => Ok(None),
}
}
pub fn deserialize_optional_pathbuf_env_var<'de, D>(
deserializer: D,
) -> Result<Option<PathBuf>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: Option<String> = Option::deserialize(deserializer)?;
match s {
Some(value) => {
let interpolated = interpolate_env_vars(&value);
Ok(Some(interpolated.parse().unwrap()))
}
None => Ok(None),
}
}
fn deserialize_optional_usize_env_var<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: Option<String> = Option::deserialize(deserializer)?;
match s {
Some(value) => {
let interpolated = interpolate_env_vars(&value);
interpolated
.parse::<usize>()
.map(Some)
.map_err(serde::de::Error::custom)
}
None => Ok(None),
}
} }
pub fn interpolate_env_vars(s: &str) -> String { pub fn interpolate_env_vars(s: &str) -> String {
@@ -430,261 +395,8 @@ pub fn interpolate_env_vars(s: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use indoc::indoc; use pretty_assertions::assert_str_eq;
use pretty_assertions::{assert_eq, assert_str_eq};
use serde::Deserialize;
use serial_test::serial; use serial_test::serial;
use std::path::PathBuf;
#[derive(Default, Deserialize, PartialEq, Eq, Debug)]
struct TestConfig {
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
string_var: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_pathbuf_env_var")]
path_var: Option<PathBuf>,
#[serde(default, deserialize_with = "deserialize_optional_usize_env_var")]
usize_var: Option<usize>,
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_is_present() {
unsafe { env::set_var("TEST_VAR_DESERIALIZE_OPTION", "localhost") };
let yaml_data = indoc!(
r#"
string_var: ${TEST_VAR_DESERIALIZE_OPTION}
path_var: /some/path
usize_var: 123
"#
);
let config: TestConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(config.string_var, Some("localhost".to_string()));
assert_eq!(config.path_var, Some(PathBuf::from("/some/path")));
assert_eq!(config.usize_var, Some(123));
unsafe { env::remove_var("TEST_VAR_DESERIALIZE_OPTION") };
}
#[test]
fn test_deserialize_optional_env_var_empty_env_var_uses_default_value_if_provided() {
let yaml_data = indoc!(
r#"
string_var: ${TEST_VAR_DESERIALIZE_OPTION_UNDEFINED:-localhost}
path_var: /some/path
usize_var: 123
"#
);
let config: TestConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(config.string_var, Some("localhost".to_string()));
assert_eq!(config.path_var, Some(PathBuf::from("/some/path")));
assert_eq!(config.usize_var, Some(123));
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_does_not_overwrite_non_env_value() {
unsafe { env::set_var("TEST_VAR_DESERIALIZE_OPTION_NO_OVERWRITE", "localhost") };
let yaml_data = indoc!(
r#"
string_var: www.example.com
path_var: /some/path
usize_var: 123
"#
);
let config: TestConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(config.string_var, Some("www.example.com".to_string()));
assert_eq!(config.path_var, Some(PathBuf::from("/some/path")));
assert_eq!(config.usize_var, Some(123));
unsafe { env::remove_var("TEST_VAR_DESERIALIZE_OPTION_NO_OVERWRITE") };
}
#[test]
fn test_deserialize_optional_env_var_empty() {
let yaml_data = indoc!(
r#"
path_var: /some/path
usize_var: 123
"#
);
let config: TestConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(config.string_var, None);
assert_eq!(config.path_var, Some(PathBuf::from("/some/path")));
assert_eq!(config.usize_var, Some(123));
}
#[test]
#[serial]
fn test_deserialize_optional_pathbuf_env_var_is_present() {
unsafe { env::set_var("TEST_VAR_DESERIALIZE_OPTION_PATHBUF", "/some/path") };
let yaml_data = indoc!(
r#"
string_var: hithere
path_var: ${TEST_VAR_DESERIALIZE_OPTION_PATHBUF}
usize_var: 123
"#
);
let config: TestConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(config.path_var, Some(PathBuf::from("/some/path")));
assert_eq!(config.string_var, Some("hithere".to_string()));
assert_eq!(config.usize_var, Some(123));
unsafe { env::remove_var("TEST_VAR_DESERIALIZE_OPTION_PATHBUF") };
}
#[test]
fn test_deserialize_optional_pathbuf_env_var_empty_env_var_uses_default_value_if_provided() {
let yaml_data = indoc!(
r#"
string_var: hithere
path_var: ${TEST_VAR_DESERIALIZE_OPTION_PATHBUF_UNDEFINED:-/some/path}
usize_var: 123
"#
);
let config: TestConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(config.path_var, Some(PathBuf::from("/some/path")));
assert_eq!(config.string_var, Some("hithere".to_string()));
assert_eq!(config.usize_var, Some(123));
}
#[test]
#[serial]
fn test_deserialize_optional_pathbuf_env_var_does_not_overwrite_non_env_value() {
unsafe {
env::set_var(
"TEST_VAR_DESERIALIZE_OPTION_PATHBUF_NO_OVERWRITE",
"/something/else",
)
};
let yaml_data = indoc!(
r#"
string_var: hithere
path_var: /some/path
usize_var: 123
"#
);
let config: TestConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(config.path_var, Some(PathBuf::from("/some/path")));
assert_eq!(config.string_var, Some("hithere".to_string()));
assert_eq!(config.usize_var, Some(123));
unsafe { env::remove_var("TEST_VAR_DESERIALIZE_OPTION_PATHBUF_NO_OVERWRITE") };
}
#[test]
fn test_deserialize_optional_pathbuf_env_var_empty() {
let yaml_data = indoc!(
r#"
string_var: hithere
usize_var: 123
"#
);
let config: TestConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(config.string_var, Some("hithere".to_string()));
assert_eq!(config.path_var, None);
assert_eq!(config.usize_var, Some(123));
}
#[test]
#[serial]
fn test_deserialize_optional_usize_env_var_is_present() {
unsafe { env::set_var("TEST_VAR_DESERIALIZE_OPTION_USIZE", "123") };
let yaml_data = indoc!(
r#"
string_var: hithere
path_var: /some/path
usize_var: ${TEST_VAR_DESERIALIZE_OPTION_USIZE}
"#
);
let config: TestConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(config.usize_var, Some(123));
assert_eq!(config.string_var, Some("hithere".to_string()));
assert_eq!(config.path_var, Some(PathBuf::from("/some/path")));
unsafe { env::remove_var("TEST_VAR_DESERIALIZE_OPTION_USIZE") };
}
#[test]
fn test_deserialize_optional_usize_env_var_uses_default_value_if_provided() {
let yaml_data = indoc!(
r#"
string_var: hithere
path_var: /some/path
usize_var: ${TEST_VAR_DESERIALIZE_OPTION_USIZE_UNDEFINED:-123}
"#
);
let config: TestConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(config.usize_var, Some(123));
assert_eq!(config.string_var, Some("hithere".to_string()));
assert_eq!(config.path_var, Some(PathBuf::from("/some/path")));
}
#[test]
#[serial]
fn test_deserialize_optional_usize_env_var_does_not_overwrite_non_env_value() {
unsafe { env::set_var("TEST_VAR_DESERIALIZE_OPTION_NO_OVERWRITE_USIZE", "456") };
let yaml_data = indoc!(
r#"
string_var: hithere
path_var: /some/path
usize_var: 123
"#
);
let config: TestConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(config.usize_var, Some(123));
assert_eq!(config.string_var, Some("hithere".to_string()));
assert_eq!(config.path_var, Some(PathBuf::from("/some/path")));
unsafe { env::remove_var("TEST_VAR_DESERIALIZE_OPTION_NO_OVERWRITE_USIZE") };
}
#[test]
fn test_deserialize_optional_usize_env_var_invalid_number() {
let yaml_data = indoc!(
r#"
string_var: hithere
path_var: /some/path
usize_var: "holo"
"#
);
let result: Result<TestConfig, _> = serde_yaml::from_str(yaml_data);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("invalid digit found in string"));
}
#[test]
fn test_deserialize_optional_usize_env_var_empty() {
let yaml_data = indoc!(
r#"
string_var: hithere
path_var: /some/path
"#
);
let config: TestConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_eq!(config.usize_var, None);
assert_eq!(config.string_var, Some("hithere".to_string()));
assert_eq!(config.path_var, Some(PathBuf::from("/some/path")));
}
#[test] #[test]
fn test_interpolate_env_vars_defaults_to_original_string_if_not_in_yaml_interpolation_format() { fn test_interpolate_env_vars_defaults_to_original_string_if_not_in_yaml_interpolation_format() {
+10
View File
@@ -20,6 +20,7 @@
//! The `config` and `providers` modules power the CLI. They can be embedded //! The `config` and `providers` modules power the CLI. They can be embedded
//! in other programs, but many functions interact with the user or the //! in other programs, but many functions interact with the user or the
//! filesystem. Prefer `no_run` doctests for those. //! filesystem. Prefer `no_run` doctests for those.
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use argon2::{ use argon2::{
Algorithm, Argon2, Params, Version, Algorithm, Argon2, Params, Version,
@@ -31,6 +32,7 @@ use chacha20poly1305::{
aead::{Aead, KeyInit, OsRng}, aead::{Aead, KeyInit, OsRng},
}; };
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use std::path::PathBuf;
use zeroize::Zeroize; use zeroize::Zeroize;
/// Configuration structures and helpers used by the CLI and library. /// Configuration structures and helpers used by the CLI and library.
pub mod config; pub mod config;
@@ -207,6 +209,14 @@ pub fn decrypt_string(password: impl Into<SecretString>, envelope: &str) -> Resu
Ok(s) Ok(s)
} }
pub(crate) fn calling_app_name() -> String {
let exe: PathBuf = std::env::current_exe().expect("unable to get current exe path");
exe.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_owned())
.expect("executable name not valid UTF-8")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
-3
View File
@@ -1,4 +1,3 @@
use crate::config::deserialize_optional_env_var;
use crate::providers::SecretProvider; use crate::providers::SecretProvider;
use anyhow::Context; use anyhow::Context;
use anyhow::Result; use anyhow::Result;
@@ -33,10 +32,8 @@ use validator::Validate;
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct AwsSecretsManagerProvider { pub struct AwsSecretsManagerProvider {
#[validate(required)] #[validate(required)]
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub aws_profile: Option<String>, pub aws_profile: Option<String>,
#[validate(required)] #[validate(required)]
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub aws_region: Option<String>, pub aws_region: Option<String>,
} }
-2
View File
@@ -1,4 +1,3 @@
use crate::config::deserialize_optional_env_var;
use crate::providers::SecretProvider; use crate::providers::SecretProvider;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use azure_identity::DefaultAzureCredential; use azure_identity::DefaultAzureCredential;
@@ -31,7 +30,6 @@ use validator::Validate;
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct AzureKeyVaultProvider { pub struct AzureKeyVaultProvider {
#[validate(required)] #[validate(required)]
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub vault_name: Option<String>, pub vault_name: Option<String>,
} }
-2
View File
@@ -1,4 +1,3 @@
use crate::config::deserialize_optional_env_var;
use crate::providers::SecretProvider; use crate::providers::SecretProvider;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use gcloud_sdk::google::cloud::secretmanager::v1; use gcloud_sdk::google::cloud::secretmanager::v1;
@@ -40,7 +39,6 @@ type SecretsManagerClient = GoogleApi<SecretManagerServiceClient<GoogleAuthMiddl
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct GcpSecretManagerProvider { pub struct GcpSecretManagerProvider {
#[validate(required)] #[validate(required)]
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub gcp_project_id: Option<String>, pub gcp_project_id: Option<String>,
} }
+3 -2
View File
@@ -1,3 +1,4 @@
use crate::calling_app_name;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use chrono::Utc; use chrono::Utc;
use dialoguer::Confirm; use dialoguer::Confirm;
@@ -25,7 +26,7 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
opts.validate() opts.validate()
.with_context(|| "invalid git sync options")?; .with_context(|| "invalid git sync options")?;
let commit_message = format!("chore: sync @ {}", Utc::now().to_rfc3339()); let commit_message = format!("chore: sync @ {}", Utc::now().to_rfc3339());
let config_dir = confy::get_configuration_file_path("gman", "vault") let config_dir = confy::get_configuration_file_path(&calling_app_name(), "vault")
.with_context(|| "get config dir")? .with_context(|| "get config dir")?
.parent() .parent()
.map(Path::to_path_buf) .map(Path::to_path_buf)
@@ -37,7 +38,7 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?; fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?;
// Move the default vault into the repo dir on first sync so only vault.yml is tracked. // Move the default vault into the repo dir on first sync so only vault.yml is tracked.
let default_vault = confy::get_configuration_file_path("gman", "vault") let default_vault = confy::get_configuration_file_path(&calling_app_name(), "vault")
.with_context(|| "get default vault path")?; .with_context(|| "get default vault path")?;
let repo_vault = repo_dir.join("vault.yml"); let repo_vault = repo_dir.join("vault.yml");
if default_vault.exists() && !repo_vault.exists() { if default_vault.exists() && !repo_vault.exists() {
-2
View File
@@ -1,4 +1,3 @@
use crate::config::deserialize_optional_env_var;
use crate::providers::{ENV_PATH, SecretProvider}; use crate::providers::{ENV_PATH, SecretProvider};
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -29,7 +28,6 @@ use validator::Validate;
#[derive(Debug, Default, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Default, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct GopassProvider { pub struct GopassProvider {
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub store: Option<String>, pub store: Option<String>,
} }
+13 -15
View File
@@ -1,5 +1,3 @@
use crate::config::deserialize_optional_env_var;
use crate::config::deserialize_optional_pathbuf_env_var;
use anyhow::{Context, anyhow, bail}; use anyhow::{Context, anyhow, bail};
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use std::collections::HashMap; use std::collections::HashMap;
@@ -15,6 +13,7 @@ use crate::providers::git_sync::{
use crate::providers::{SecretProvider, SupportedProvider}; use crate::providers::{SecretProvider, SupportedProvider};
use crate::{ use crate::{
ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION, 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 anyhow::Result;
use argon2::{Algorithm, Argon2, Params, Version}; use argon2::{Algorithm, Argon2, Params, Version};
@@ -52,28 +51,26 @@ use validator::Validate;
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct LocalProvider { pub struct LocalProvider {
#[serde(default, deserialize_with = "deserialize_optional_pathbuf_env_var")]
pub password_file: Option<PathBuf>, pub password_file: Option<PathBuf>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub git_branch: Option<String>, pub git_branch: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub git_remote_url: Option<String>, pub git_remote_url: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub git_user_name: Option<String>, pub git_user_name: Option<String>,
#[validate(email)] #[validate(email)]
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub git_user_email: Option<String>, pub git_user_email: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_pathbuf_env_var")]
pub git_executable: Option<PathBuf>, pub git_executable: Option<PathBuf>,
#[serde(skip)] #[serde(skip)]
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub runtime_provider_name: Option<String>, pub runtime_provider_name: Option<String>,
} }
impl Default for LocalProvider { impl Default for LocalProvider {
fn default() -> Self { fn default() -> Self {
let password_file = match Config::local_provider_password_file() {
p if p.exists() => Some(p),
_ => None,
};
Self { Self {
password_file: Config::local_provider_password_file(), password_file,
git_branch: Some("main".into()), git_branch: Some("main".into()),
git_remote_url: None, git_remote_url: None,
git_user_name: None, git_user_name: None,
@@ -256,7 +253,7 @@ impl LocalProvider {
fn persist_git_settings_to_config(&self) -> Result<()> { fn persist_git_settings_to_config(&self) -> Result<()> {
debug!("Saving updated config (only current local provider)"); debug!("Saving updated config (only current local provider)");
let mut cfg = load_config().with_context(|| "failed to load existing config")?; let mut cfg = load_config(true).with_context(|| "failed to load existing config")?;
let target_name = self.runtime_provider_name.clone(); let target_name = self.runtime_provider_name.clone();
let mut updated = false; let mut updated = false;
@@ -295,7 +292,7 @@ impl LocalProvider {
let s = serde_yaml::to_string(&cfg)?; let s = serde_yaml::to_string(&cfg)?;
fs::write(&path, s).with_context(|| format!("failed to write {}", path.display()))?; fs::write(&path, s).with_context(|| format!("failed to write {}", path.display()))?;
} else { } else {
confy::store("gman", "config", &cfg) confy::store(&calling_app_name(), "config", &cfg)
.with_context(|| "failed to save updated config via confy")?; .with_context(|| "failed to save updated config via confy")?;
} }
@@ -344,10 +341,11 @@ 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);
if let Some(xdg) = xdg_path { if let Some(xdg) = xdg_path {
return Ok(xdg.join("gman").join("vault.yml")); return Ok(xdg.join(calling_app_name()).join("vault.yml"));
} }
confy::get_configuration_file_path("gman", "vault").with_context(|| "get config dir") confy::get_configuration_file_path(&calling_app_name(), "vault")
.with_context(|| "get config dir")
} }
fn base_config_dir() -> Result<PathBuf> { fn base_config_dir() -> Result<PathBuf> {
@@ -569,7 +567,7 @@ mod tests {
fn persist_only_target_local_provider_git_settings() { fn persist_only_target_local_provider_git_settings() {
let td = tempdir().unwrap(); let td = tempdir().unwrap();
let xdg = td.path().join("xdg"); let xdg = td.path().join("xdg");
let app_dir = xdg.join("gman"); let app_dir = xdg.join(calling_app_name());
fs::create_dir_all(&app_dir).unwrap(); fs::create_dir_all(&app_dir).unwrap();
unsafe { unsafe {
std_env::set_var("XDG_CONFIG_HOME", &xdg); std_env::set_var("XDG_CONFIG_HOME", &xdg);
-12
View File
@@ -130,18 +130,6 @@ fn cli_shows_help() {
.stdout(predicate::str::contains("Usage").or(predicate::str::contains("Add"))); .stdout(predicate::str::contains("Usage").or(predicate::str::contains("Add")));
} }
#[test]
fn cli_completions_bash() {
let (_td, cfg, cache) = setup_env();
let mut cmd = Command::cargo_bin("gman").unwrap();
cmd.env("XDG_CACHE_HOME", &cache)
.env("XDG_CONFIG_HOME", &cfg)
.args(["completions", "bash"]);
cmd.assert()
.success()
.stdout(predicate::str::contains("_gman").or(predicate::str::contains("complete -F")));
}
#[test] #[test]
fn cli_add_get_list_update_delete_roundtrip() { fn cli_add_get_list_update_delete_roundtrip() {
let (td, xdg_cfg, xdg_cache) = setup_env(); let (td, xdg_cfg, xdg_cache) = setup_env();
+8 -10
View File
@@ -252,16 +252,14 @@ mod tests {
#[test] #[test]
fn test_config_local_provider_password_file() { fn test_config_local_provider_password_file() {
let path = Config::local_provider_password_file(); let path = Config::local_provider_password_file();
let expected_path = dirs::home_dir().map(|p| p.join(".gman_password")); // Derive expected filename based on current test executable name
if let Some(p) = &expected_path { let exe = std::env::current_exe().expect("current_exe");
if !p.exists() { let stem = exe
assert_eq!(path, None); .file_stem()
} else { .and_then(|s| s.to_str())
assert_eq!(path, expected_path); .expect("utf-8 file stem");
} let expected = dirs::home_dir().map(|p| p.join(format!(".{}_password", stem)));
} else { assert_eq!(Some(path), expected);
assert_eq!(path, None);
}
} }
#[test] #[test]
+5 -4
View File
@@ -58,10 +58,11 @@ fn test_local_provider_invalid_email() {
#[test] #[test]
fn test_local_provider_default() { fn test_local_provider_default() {
let provider = LocalProvider::default(); let provider = LocalProvider::default();
assert_eq!( let expected_pw = {
provider.password_file, let p = Config::local_provider_password_file();
Config::local_provider_password_file() if p.exists() { Some(p) } else { None }
); };
assert_eq!(provider.password_file, expected_pw);
assert_eq!(provider.git_branch, Some("main".into())); assert_eq!(provider.git_branch, Some("main".into()));
assert_eq!(provider.git_remote_url, None); assert_eq!(provider.git_remote_url, None);
assert_eq!(provider.git_user_name, None); assert_eq!(provider.git_user_name, None);