14 Commits

9 changed files with 572 additions and 219 deletions
+18 -1
View File
@@ -5,7 +5,24 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.0.1] - 2025-09-10
## 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)
Generated
+248 -165
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -1,10 +1,16 @@
[package]
name = "gman"
version = "0.1.0"
version = "0.2.1"
edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
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"
repository = "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"
assert_cmd = "2.0.16"
predicates = "3.1.2"
serial_test = "3.2.0"
[[bin]]
bench = false
+24
View File
@@ -89,11 +89,13 @@ gman aws sts get-caller-identity
- [Features](#features)
- [Installation](#installation)
- [Configuration](#configuration)
- [Environment Variable Interpolation](#environment-variable-interpolation)
- [Providers](#providers)
- [Local](#provider-local)
- [AWS Secrets Manager](#provider-aws_secrets_manager)
- [GCP Secret Manager](#provider-gcp_secret_manager)
- [Azure Key Vault](#provider-azure_key_vault)
- [Gopass](#provider-gopass)
- [Run Configurations](#run-configurations)
- [Specifying a Default Provider per Run Config](#specifying-a-default-provider-per-run-config)
- [Environment Variable Secret Injection](#environment-variable-secret-injection)
@@ -241,6 +243,28 @@ providers:
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
`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
+142 -4
View File
@@ -1,8 +1,8 @@
use crate::command::preview_command;
use anyhow::{Context, Result, anyhow};
use anyhow::{anyhow, Context, Result};
use clap_complete::CompletionCandidate;
use futures::future::join_all;
use gman::config::{Config, RunConfig, load_config};
use gman::config::{load_config, Config, RunConfig};
use log::{debug, error};
use regex::Regex;
use std::collections::HashMap;
@@ -257,7 +257,7 @@ pub fn parse_args(
pub fn run_config_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
match load_config() {
match load_config(true) {
Ok(config) => {
if let Some(run_configs) = config.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> {
let cur = current.to_string_lossy();
match load_config() {
match load_config(true) {
Ok(config) => {
let mut provider_config = match config.extract_provider_config(None) {
Ok(pc) => pc,
@@ -307,8 +325,11 @@ mod tests {
use crate::cli::generate_files_secret_injections;
use gman::config::{Config, RunConfig};
use pretty_assertions::{assert_eq, assert_str_eq};
use serial_test::serial;
use std::collections::HashMap;
use std::env as std_env;
use std::ffi::OsString;
use tempfile::tempdir;
#[test]
fn test_generate_files_secret_injections() {
@@ -409,4 +430,121 @@ mod tests {
.expect_err("expected failed secret resolution in dry_run");
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") };
}
}
+8 -18
View File
@@ -1,14 +1,15 @@
use crate::cli::provider_completer;
use crate::cli::run_config_completer;
use crate::cli::secrets_completer;
use anyhow::{Context, Result};
use clap::Subcommand;
use clap::{
CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version,
crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser, ValueEnum,
};
use clap_complete::{ArgValueCompleter, CompleteEnv};
use crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode};
use gman::config::{Config, get_config_file_path, load_config};
use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen};
use gman::config::{get_config_file_path, load_config, Config};
use std::ffi::OsString;
use std::io::{self, IsTerminal, Read, Write};
use std::panic::PanicHookInfo;
@@ -51,7 +52,7 @@ struct Cli {
output: Option<OutputFormat>,
/// 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>,
/// Specify a run profile to use when wrapping a command
@@ -122,13 +123,6 @@ enum Commands {
/// configured in a corresponding run profile
#[command(external_subcommand)]
External(Vec<OsString>),
/// Generate shell completion scripts
Completions {
/// The shell to generate the script for
#[arg(value_enum)]
shell: clap_complete::Shell,
},
}
#[tokio::main]
@@ -156,7 +150,7 @@ async fn main() -> Result<()> {
exit(1);
}
let config = load_config()?;
let config = load_config(true)?;
let mut provider_config = config.extract_provider_config(cli.provider.clone())?;
let secrets_provider = provider_config.extract_provider();
@@ -237,7 +231,8 @@ async fn main() -> Result<()> {
}
}
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")?;
let new_config = Editor::new()
.edit(&config_yaml)
@@ -266,11 +261,6 @@ async fn main() -> Result<()> {
Commands::External(tokens) => {
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(())
+115 -8
View File
@@ -26,6 +26,7 @@ use crate::providers::{SecretProvider, SupportedProvider};
use anyhow::{Context, Result};
use collections::HashSet;
use log::debug;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::skip_serializing_none;
@@ -286,10 +287,11 @@ impl Config {
///
/// ```no_run
/// # 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);
/// ```
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 mut config: Config = if let Some(base) = xdg_path.as_ref() {
@@ -298,17 +300,22 @@ pub fn load_config() -> Result<Config> {
let yaml = app_dir.join("config.yaml");
if yml.exists() || yaml.exists() {
let load_path = if yml.exists() { &yml } else { &yaml };
let content = fs::read_to_string(load_path)
.with_context(|| format!("failed to read config file '{}'", load_path.display()))?;
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())
})?;
cfg
} else {
confy::load("gman", "config")?
load_confy_config(interpolate)?
}
} else {
confy::load("gman", "config")?
load_confy_config(interpolate)?
};
config.validate()?;
@@ -331,7 +338,21 @@ pub fn load_config() -> Result<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> {
if let Some(base) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) {
let dir = base.join("gman");
@@ -340,8 +361,94 @@ pub fn get_config_file_path() -> Result<PathBuf> {
if yml.exists() || yaml.exists() {
return Ok(if yml.exists() { yml } else { yaml });
}
// Prefer .yml if creating anew
return Ok(dir.join("config.yml"));
}
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"
);
}
}
+8 -8
View File
@@ -1,14 +1,14 @@
use anyhow::{Context, anyhow, bail};
use anyhow::{anyhow, bail, Context};
use secrecy::{ExposeSecret, SecretString};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::{env, fs};
use zeroize::Zeroize;
use crate::config::{Config, get_config_file_path, load_config};
use crate::config::{get_config_file_path, load_config, Config};
use crate::providers::git_sync::{
SyncOpts, default_git_email, default_git_username, ensure_git_available, repo_name_from_url,
resolve_git, sync_and_push,
default_git_email, default_git_username, ensure_git_available, repo_name_from_url, resolve_git,
sync_and_push, SyncOpts,
};
use crate::providers::{SecretProvider, SupportedProvider};
use crate::{
@@ -16,13 +16,13 @@ use crate::{
};
use anyhow::Result;
use argon2::{Algorithm, Argon2, Params, Version};
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use chacha20poly1305::aead::rand_core::RngCore;
use chacha20poly1305::{
Key, XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, OsRng},
Key, XChaCha20Poly1305, XNonce,
};
use dialoguer::{Input, theme};
use dialoguer::{theme, Input};
use log::{debug, error};
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
@@ -247,7 +247,7 @@ impl LocalProvider {
fn persist_git_settings_to_config(&self) -> Result<()> {
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 mut updated = false;
-12
View File
@@ -130,18 +130,6 @@ fn cli_shows_help() {
.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]
fn cli_add_get_list_update_delete_roundtrip() {
let (td, xdg_cfg, xdg_cache) = setup_env();