20 Commits

Author SHA1 Message Date
github-actions[bot]
e37b80a262 bump: version 0.2.1 → 0.2.2 [skip ci] 2025-09-30 22:03:17 +00:00
3ce62c272e build: Updated changelog format 2025-09-30 15:42:41 -06:00
21b771507c Merge remote-tracking branch 'origin/main' 2025-09-30 15:40:36 -06:00
508c8b7feb style: Reformatted code 2025-09-30 15:40:27 -06:00
github-actions[bot]
33a889fa67 chore: bump Cargo.toml to 0.2.2 2025-09-30 21:37:14 +00:00
github-actions[bot]
7ddb7812fc bump: version 0.2.1 → 0.2.2 [skip ci] 2025-09-30 21:37:04 +00:00
9e11648a7c refactor: Environment variable interpolation in config file works globally, not based on type 2025-09-30 15:35:48 -06:00
github-actions[bot]
ed79af2a8a chore: bump Cargo.toml to 0.2.1 2025-09-30 17:44:19 +00:00
github-actions[bot]
443fbcf305 bump: version 0.2.0 → 0.2.1 [skip ci] 2025-09-30 17:44:08 +00:00
78d7e90e68 feat: Environment variable interpolation in the Gman configuration file 2025-09-30 11:10:20 -06:00
01d4819160 fix: Corrected tab completions for the provider flag 2025-09-30 09:25:29 -06:00
github-actions[bot]
e200a32f5a bump: version 0.1.0 → 0.2.0 [skip ci] 2025-09-30 03:56:50 +00:00
008b33b044 docs: Updated changelog once more 2025-09-29 21:35:02 -06:00
Alex Clarke
f35afac20f docs: Update Changeling changelog 2025-09-29 18:48:09 -07:00
262a3d6435 build: Fixed build dependencies 2025-09-29 18:16:39 -06:00
eb9e671818 Merge branch 'main' of github.com:Dark-Alex-17/gman 2025-09-29 18:15:44 -06:00
efc8af2c93 docs: Updated the gopass provider docs 2025-09-29 18:15:30 -06:00
3d38ac9b51 docs: Added gopass docs to README 2025-09-29 17:55:06 -06:00
github-actions[bot]
8d40c3773f chore: bump Cargo.toml to 0.2.0 2025-09-29 23:52:24 +00:00
github-actions[bot]
16ce245218 bump: version 0.1.0 → 0.2.0 [skip ci] 2025-09-29 23:52:12 +00:00
9 changed files with 562 additions and 206 deletions
+24 -1
View File
@@ -5,7 +5,30 @@ 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).
## [0.0.1] - 2025-09-10 ## 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)
### Feat
- Environment variable interpolation in the Gman configuration file
### Fix
- Corrected tab completions for the provider flag
## v0.2.0 (2025-09-30)
### Feat
- gopass support
- Added command aliases to make the CLI more universal
- Added dynamic tab completions for the profile, providers, and the secrets in any given secret manager
- Users can now specify a default provider to use with each run config, so they don't need to explicitly specify which to use when wanting to run different applications.
## v0.1.0 (2025-09-17) ## v0.1.0 (2025-09-17)
Generated
+248 -165
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -1,10 +1,16 @@
[package] [package]
name = "gman" name = "gman"
version = "0.1.0" version = "0.2.2"
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"
keywords = ["cli", "secrets-manager", "secret-injection", "command-runner", "vault"] keywords = [
"cli",
"secrets-manager",
"secret-injection",
"command-runner",
"vault",
]
documentation = "https://github.com/Dark-Alex-17/gman" documentation = "https://github.com/Dark-Alex-17/gman"
repository = "https://github.com/Dark-Alex-17/gman" repository = "https://github.com/Dark-Alex-17/gman"
homepage = "https://github.com/Dark-Alex-17/gman" homepage = "https://github.com/Dark-Alex-17/gman"
@@ -73,7 +79,7 @@ pretty_assertions = "1.4.1"
proptest = "1.5.0" proptest = "1.5.0"
assert_cmd = "2.0.16" assert_cmd = "2.0.16"
predicates = "3.1.2" predicates = "3.1.2"
serial_test = "3.2.0"
[[bin]] [[bin]]
bench = false bench = false
+24
View File
@@ -89,11 +89,13 @@ 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)
- [GCP Secret Manager](#provider-gcp_secret_manager) - [GCP Secret Manager](#provider-gcp_secret_manager)
- [Azure Key Vault](#provider-azure_key_vault) - [Azure Key Vault](#provider-azure_key_vault)
- [Gopass](#provider-gopass)
- [Run Configurations](#run-configurations) - [Run Configurations](#run-configurations)
- [Specifying a Default Provider per Run Config](#specifying-a-default-provider-per-run-config) - [Specifying a Default Provider per Run Config](#specifying-a-default-provider-per-run-config)
- [Environment Variable Secret Injection](#environment-variable-secret-injection) - [Environment Variable Secret Injection](#environment-variable-secret-injection)
@@ -241,6 +243,28 @@ providers:
run_configs: [] run_configs: []
``` ```
### Environment Variable Interpolation
The config file supports environment variable interpolation using `${VAR_NAME}` syntax. For example, to use an
AWS profile from your environment:
```yaml
providers:
- name: aws
type: aws_secrets_manager
aws_profile: ${AWS_PROFILE} # Uses the AWS_PROFILE env var
aws_region: us-east-1
```
Or to set a default profile to use when `AWS_PROFILE` is unset:
```yaml
providers:
- name: aws
type: aws_secrets_manager
aws_profile: ${AWS_PROFILE:-default} # Uses 'default' if AWS_PROFILE is unset
aws_region: us-east-1
```
## 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
+140 -2
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
@@ -280,9 +280,27 @@ pub fn run_config_completer(current: &OsStr) -> Vec<CompletionCandidate> {
} }
} }
pub fn provider_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
match load_config(true) {
Ok(config) => config
.providers
.iter()
.filter(|pc| {
pc.name
.as_ref()
.expect("run config has no name")
.starts_with(&*cur)
})
.map(|pc| CompletionCandidate::new(pc.name.as_ref().expect("provider has no name")))
.collect(),
Err(_) => vec![],
}
}
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,
@@ -307,8 +325,11 @@ mod tests {
use crate::cli::generate_files_secret_injections; use crate::cli::generate_files_secret_injections;
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 std::collections::HashMap; use std::collections::HashMap;
use std::env as std_env;
use std::ffi::OsString; use std::ffi::OsString;
use tempfile::tempdir;
#[test] #[test]
fn test_generate_files_secret_injections() { fn test_generate_files_secret_injections() {
@@ -409,4 +430,121 @@ mod tests {
.expect_err("expected failed secret resolution in dry_run"); .expect_err("expected failed secret resolution in dry_run");
assert!(err.to_string().contains("Failed to fetch")); assert!(err.to_string().contains("Failed to fetch"));
} }
#[test]
#[serial]
fn test_run_config_completer_filters_by_prefix() {
let td = tempdir().unwrap();
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) };
let yaml = indoc::indoc! {
"---
default_provider: local
providers:
- name: local
type: local
run_configs:
- name: echo
secrets: [API_KEY]
- name: docker
secrets: [DB_PASSWORD]
- name: aws
secrets: [AWS_ACCESS_KEY_ID]
"
};
fs::write(app_dir.join("config.yml"), yaml).unwrap();
let out = run_config_completer(OsStr::new("do"));
assert_eq!(out.len(), 1);
// Compare via debug string to avoid depending on crate internals
let rendered = format!("{:?}", &out[0]);
assert!(rendered.contains("docker"), "got: {}", rendered);
unsafe { std_env::remove_var("XDG_CONFIG_HOME") };
}
#[test]
#[serial]
fn test_provider_completer_lists_matching_providers() {
let td = tempdir().unwrap();
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) };
let yaml = indoc::indoc! {
"---
default_provider: local
providers:
- name: local
type: local
- name: prod
type: local
run_configs:
- name: echo
secrets: [API_KEY]
"
};
fs::write(app_dir.join("config.yml"), yaml).unwrap();
// Prefix 'p' should match only 'prod'
let out = provider_completer(OsStr::new("p"));
assert_eq!(out.len(), 1);
let rendered = format!("{:?}", &out[0]);
assert!(rendered.contains("prod"), "got: {}", rendered);
// Empty prefix returns at least both providers
let out_all = provider_completer(OsStr::new(""));
assert!(out_all.len() >= 2);
unsafe { std_env::remove_var("XDG_CONFIG_HOME") };
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_secrets_completer_filters_keys_by_prefix() {
let td = tempdir().unwrap();
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) };
let yaml = indoc::indoc! {
"---
default_provider: local
providers:
- name: local
type: local
run_configs:
- name: echo
secrets: [API_KEY]
"
};
fs::write(app_dir.join("config.yml"), yaml).unwrap();
// Seed a minimal vault with keys (values are irrelevant for listing)
let vault_yaml = indoc::indoc! {
"---
API_KEY: dummy
DB_PASSWORD: dummy
AWS_ACCESS_KEY_ID: dummy
"
};
fs::write(app_dir.join("vault.yml"), vault_yaml).unwrap();
let out = secrets_completer(OsStr::new("AWS"));
assert_eq!(out.len(), 1);
let rendered = format!("{:?}", &out[0]);
assert!(rendered.contains("AWS_ACCESS_KEY_ID"), "got: {}", rendered);
let out2 = secrets_completer(OsStr::new("DB_"));
assert_eq!(out2.len(), 1);
let rendered2 = format!("{:?}", &out2[0]);
assert!(rendered2.contains("DB_PASSWORD"), "got: {}", rendered2);
unsafe { std_env::remove_var("XDG_CONFIG_HOME") };
}
} }
+5 -15
View File
@@ -1,3 +1,4 @@
use crate::cli::provider_completer;
use crate::cli::run_config_completer; use crate::cli::run_config_completer;
use crate::cli::secrets_completer; use crate::cli::secrets_completer;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -51,7 +52,7 @@ struct Cli {
output: Option<OutputFormat>, output: Option<OutputFormat>,
/// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local')) /// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local'))
#[arg(long, global = true, env = "GMAN_PROVIDER", value_parser = ["local", "aws_secrets_manager", "azure_key_vault", "gcp_secret_manager", "gopass"])] #[arg(long, global = true, env = "GMAN_PROVIDER", add = ArgValueCompleter::new(provider_completer))]
provider: Option<String>, provider: Option<String>,
/// Specify a run profile to use when wrapping a command /// Specify a run profile to use when wrapping a command
@@ -122,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]
@@ -156,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();
@@ -237,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)
@@ -266,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(())
+111 -7
View File
@@ -26,6 +26,7 @@ use crate::providers::{SecretProvider, SupportedProvider};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use collections::HashSet; use collections::HashSet;
use log::debug; use log::debug;
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::serde_as; use serde_with::serde_as;
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
@@ -286,10 +287,11 @@ impl Config {
/// ///
/// ```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() {
@@ -298,17 +300,20 @@ pub fn load_config() -> Result<Config> {
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()?;
@@ -331,7 +336,20 @@ pub fn load_config() -> Result<Config> {
Ok(config) Ok(config)
} }
/// Returns the configuration file path that `confy` will use for this app. fn load_confy_config(interpolate: bool) -> Result<Config> {
let load_path = confy::get_configuration_file_path("gman", "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
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("gman");
@@ -340,8 +358,94 @@ pub fn get_config_file_path() -> Result<PathBuf> {
if yml.exists() || yaml.exists() { if yml.exists() || yaml.exists() {
return Ok(if yml.exists() { yml } else { yaml }); return Ok(if yml.exists() { yml } else { yaml });
} }
// Prefer .yml if creating anew
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("gman", "config")?)
} }
pub fn interpolate_env_vars(s: &str) -> String {
let result = s.to_string();
let scrubbing_regex = Regex::new(r#"[\s{}^()\[\]\\|`'"]+"#).unwrap();
let var_regex = Regex::new(r"\$\{(.*?)(:-.+)?}").unwrap();
var_regex
.replace_all(s, |caps: &regex::Captures<'_>| {
if let Some(mat) = caps.get(1) {
if let Ok(value) = env::var(mat.as_str()) {
return scrubbing_regex.replace_all(&value, "").to_string();
} else if let Some(default_value) = caps.get(2) {
return scrubbing_regex
.replace_all(
default_value
.as_str()
.strip_prefix(":-")
.expect("unable to strip ':-' prefix from default value"),
"",
)
.to_string();
}
}
scrubbing_regex.replace_all(&result, "").to_string()
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_str_eq;
use serial_test::serial;
#[test]
fn test_interpolate_env_vars_defaults_to_original_string_if_not_in_yaml_interpolation_format() {
let var = interpolate_env_vars("TEST_VAR_INTERPOLATION_NON_YAML");
assert_str_eq!(var, "TEST_VAR_INTERPOLATION_NON_YAML");
}
#[test]
#[serial]
fn test_interpolate_env_vars_scrubs_all_unnecessary_characters() {
unsafe {
env::set_var(
"TEST_VAR_INTERPOLATION_UNNECESSARY_CHARACTERS",
r#"""
`"'https://dontdo:this@testing.com/query?test=%20query#results'"` {([\|])}
"""#,
)
};
let var = interpolate_env_vars("${TEST_VAR_INTERPOLATION_UNNECESSARY_CHARACTERS}");
assert_str_eq!(
var,
"https://dontdo:this@testing.com/query?test=%20query#results"
);
unsafe { env::remove_var("TEST_VAR_INTERPOLATION_UNNECESSARY_CHARACTERS") };
}
#[test]
#[serial]
fn test_interpolate_env_vars_scrubs_all_unnecessary_characters_for_default_values() {
let var = interpolate_env_vars(
r#"${UNSET:-`"'https://dontdo:this@testing.com/query?test=%20query#results'"` {([\|])}}"#,
);
assert_str_eq!(
var,
"https://dontdo:this@testing.com/query?test=%20query#results"
);
}
#[test]
fn test_interpolate_env_vars_scrubs_all_unnecessary_characters_from_non_environment_variable() {
let var =
interpolate_env_vars("https://dontdo:this@testing.com/query?test=%20query#results");
assert_str_eq!(
var,
"https://dontdo:this@testing.com/query?test=%20query#results"
);
}
}
+1 -1
View File
@@ -247,7 +247,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;
-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();