Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed79af2a8a | ||
|
|
443fbcf305 | ||
| 78d7e90e68 | |||
| 01d4819160 | |||
|
|
e200a32f5a | ||
| 008b33b044 | |||
|
|
f35afac20f | ||
| 262a3d6435 | |||
| eb9e671818 | |||
| efc8af2c93 | |||
| 3d38ac9b51 | |||
|
|
8d40c3773f | ||
|
|
16ce245218 | ||
| a64f4dbf79 | |||
| 1b83d9b199 | |||
| f006503736 | |||
| 9abd2f88cf | |||
| 29acad5eed |
+18
-1
@@ -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
+285
-165
File diff suppressed because it is too large
Load Diff
+12
-4
@@ -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"
|
||||
@@ -25,7 +31,7 @@ clap = { version = "4.5.47", features = [
|
||||
"env",
|
||||
"wrap_help",
|
||||
] }
|
||||
clap_complete = "4.5.57"
|
||||
clap_complete = { version = "4.5.57", features = ["unstable-dynamic"] }
|
||||
confy = { version = "1.0.0", default-features = false, features = [
|
||||
"yaml_conf",
|
||||
] }
|
||||
@@ -59,6 +65,8 @@ crc32c = "0.6.8"
|
||||
azure_identity = "0.27.0"
|
||||
azure_security_keyvault_secrets = "0.6.0"
|
||||
aws-lc-sys = { version = "0.31.0", features = ["bindgen"] }
|
||||
which = "8.0.0"
|
||||
once_cell = "1.21.3"
|
||||
|
||||
[target.'cfg(all(target_os="linux", target_env="musl"))'.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
@@ -71,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
|
||||
|
||||
@@ -94,7 +94,9 @@ gman aws sts get-caller-identity
|
||||
- [AWS Secrets Manager](#provider-aws_secrets_manager)
|
||||
- [GCP Secret Manager](#provider-gcp_secret_manager)
|
||||
- [Azure Key Vault](#provider-azure_key_vault)
|
||||
- [Run Configurations](#run-configurations)
|
||||
- [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)
|
||||
- [Inject Secrets via Command-Line Flags](#inject-secrets-via-command-line-flags)
|
||||
- [Inject Secrets into Files](#inject-secrets-into-files)
|
||||
@@ -174,6 +176,22 @@ To use a binary from the releases page on Linux/MacOS, do the following:
|
||||
3. Extract the binary with `tar -C /usr/local/bin -xzf gman-<arch>.tar.gz` (Note: This may require `sudo`)
|
||||
4. Now you can run `gman`!
|
||||
|
||||
### Enable Tab Completion
|
||||
`gman` supports shell tab completion for `bash`, `zsh`, and `fish`. To enable it, run the following command for your
|
||||
shell:
|
||||
|
||||
```shell
|
||||
# Bash
|
||||
echo 'source <(COMPLETE=bash gman)' >> ~/.bashrc
|
||||
# Zsh
|
||||
echo 'source <(COMPLETE=zsh gman)' >> ~/.zshrc
|
||||
# Fish
|
||||
echo 'COMPLETE=fish gman | source' >> ~/.config/fish/config.fish
|
||||
```
|
||||
|
||||
Then restart your shell or `source` the appropriate config file.
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
`gman` reads a YAML configuration file located at an OS-specific path:
|
||||
@@ -224,6 +242,31 @@ 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
|
||||
```
|
||||
|
||||
**Important Note:** Environment variable interpolation is only supported in string or numeric fields. It is not
|
||||
supported in lists or maps.
|
||||
|
||||
## 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
|
||||
@@ -245,6 +288,7 @@ documented and added without breaking existing setups. The following table shows
|
||||
| [`hashicorp_vault`](https://www.hashicorp.com/en/products/vault) | 🕒 | | |
|
||||
| [`azure_key_vault`](https://azure.microsoft.com/en-us/products/key-vault/) | ✅ | [Azure Key Vault](#provider-azure_key_vault) | |
|
||||
| [`gcp_secret_manager`](https://cloud.google.com/security/products/secret-manager?hl=en) | ✅ | [GCP Secret Manager](#provider-gcp_secret_manager) | |
|
||||
| [`gopass`](https://www.gopass.pw/) | ✅ | | |
|
||||
| [`1password`](https://1password.com/) | 🕒 | | |
|
||||
| [`bitwarden`](https://bitwarden.com/) | 🕒 | | |
|
||||
| [`dashlane`](https://www.dashlane.com/) | 🕒 | | Waiting for CLI support for adding secrets |
|
||||
@@ -388,6 +432,26 @@ Important notes:
|
||||
- Ensure your identity has the necessary Key Vault permissions (RBAC such as `Key Vault Secrets User`/`Administrator`,
|
||||
or appropriate access policies) for get/set/list/delete.
|
||||
|
||||
### Provider: `gopass`
|
||||
The `gopass` provider uses [gopass](https://www.gopass.pw/) as the backing storage location for secrets.
|
||||
|
||||
- Optional: `store` (string) to specify a particular gopass store if you have multiple.
|
||||
|
||||
Configuration example:
|
||||
|
||||
```yaml
|
||||
default_provider: gopass
|
||||
providers:
|
||||
- name: gopass
|
||||
type: gopass
|
||||
store: my-store # Optional; if omitted, uses the default configured gopass store
|
||||
```
|
||||
|
||||
Important notes:
|
||||
- Ensure `gopass` is installed and initialized on your system.
|
||||
- Secrets are managed using gopass's native commands; `gman` acts as a wrapper to interface with gopass.
|
||||
- Updates overwrite existing secrets
|
||||
- If no store is specified, the default gopass store is used and `gman sync` will sync with all configured stores.
|
||||
## Run Configurations
|
||||
|
||||
Run configurations (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are
|
||||
@@ -404,6 +468,45 @@ will error out and report that it could not find the run config with that name.
|
||||
You can manually specify which run configuration to use with the `--profile` flag. Again, if no profile is found with
|
||||
that name, `gman` will error out.
|
||||
|
||||
|
||||
### Specifying a Default Provider per Run Config
|
||||
All run configs also support the `provider` field, which lets you override the default provider for that specific
|
||||
profile. This is useful if you have multiple providers configured and want to use a different one for a specific command
|
||||
, but that provider may not be the `default_provider`, and you don't want to have to specify `--provider` on the command
|
||||
line every time.
|
||||
|
||||
For Example:
|
||||
```yaml
|
||||
default_provider: local
|
||||
run_configs:
|
||||
# `gman aws ...` uses the `aws` provider instead of `local` if no
|
||||
# `--provider` is given.
|
||||
- name: aws
|
||||
# Can be overridden by explicitly specifying a `--provider`
|
||||
provider: aws
|
||||
secrets:
|
||||
- DB_USERNAME
|
||||
- DB_PASSWORD
|
||||
# `gman docker ...` uses the default_provider `local` because no
|
||||
# `provider` is specified.
|
||||
- name: docker
|
||||
secrets:
|
||||
- MY_APP_API_KEY
|
||||
- MY_APP_DB_PASSWORD
|
||||
# `gman managarr ...` uses the `local` provider; This is useful
|
||||
# if you change the default provider to something else.
|
||||
- name: managarr
|
||||
provider: local
|
||||
secrets:
|
||||
- RADARR_API_KEY
|
||||
- SONARR_API_KEY
|
||||
files:
|
||||
- /home/user/.config/managarr/config.yml
|
||||
```
|
||||
|
||||
**Important Note:** Any run config with a `provider` field can be overridden by specifying `--provider` on the command
|
||||
line.
|
||||
|
||||
### Environment Variable Secret Injection
|
||||
|
||||
By default, secrets are injected as environment variables. The two required fields are `name` and `secrets`.
|
||||
|
||||
+201
-35
@@ -1,21 +1,22 @@
|
||||
use crate::command::preview_command;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use clap_complete::CompletionCandidate;
|
||||
use futures::future::join_all;
|
||||
use gman::config::{Config, RunConfig};
|
||||
use gman::providers::SecretProvider;
|
||||
use gman::config::{Config, RunConfig, load_config};
|
||||
use log::{debug, error};
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}";
|
||||
const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}";
|
||||
|
||||
pub async fn wrap_and_run_command(
|
||||
secrets_provider: &mut dyn SecretProvider,
|
||||
provider: Option<String>,
|
||||
config: &Config,
|
||||
tokens: Vec<OsString>,
|
||||
profile_name: Option<String>,
|
||||
@@ -36,6 +37,9 @@ pub async fn wrap_and_run_command(
|
||||
.find(|c| c.name.as_deref() == Some(run_config_profile_name))
|
||||
});
|
||||
if let Some(run_cfg) = run_config_opt {
|
||||
let mut provider_config =
|
||||
config.extract_provider_config(provider.or(run_cfg.provider.clone()))?;
|
||||
let secrets_provider = provider_config.extract_provider();
|
||||
let secrets_result_futures = run_cfg
|
||||
.secrets
|
||||
.as_ref()
|
||||
@@ -163,7 +167,7 @@ fn generate_files_secret_injections(
|
||||
secrets: HashMap<&str, String>,
|
||||
run_config: &RunConfig,
|
||||
) -> Result<Vec<(PathBuf, String, String)>> {
|
||||
let re = Regex::new(r"\{\{(.+)\}\}")?;
|
||||
let re = Regex::new(r"\{\{(.+)}}")?;
|
||||
let mut results = Vec::new();
|
||||
for file in run_config
|
||||
.files
|
||||
@@ -251,34 +255,81 @@ pub fn parse_args(
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
pub fn run_config_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
let cur = current.to_string_lossy();
|
||||
match load_config() {
|
||||
Ok(config) => {
|
||||
if let Some(run_configs) = config.run_configs {
|
||||
run_configs
|
||||
.iter()
|
||||
.filter(|rc| {
|
||||
rc.name
|
||||
.as_ref()
|
||||
.expect("run config has no name")
|
||||
.starts_with(&*cur)
|
||||
})
|
||||
.map(|rc| {
|
||||
CompletionCandidate::new(rc.name.as_ref().expect("run config has no name"))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provider_completer(current: &OsStr) -> Vec<CompletionCandidate> {
|
||||
let cur = current.to_string_lossy();
|
||||
match load_config() {
|
||||
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() {
|
||||
Ok(config) => {
|
||||
let mut provider_config = match config.extract_provider_config(None) {
|
||||
Ok(pc) => pc,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let secrets_provider = provider_config.extract_provider();
|
||||
let h = Handle::current();
|
||||
tokio::task::block_in_place(|| h.block_on(secrets_provider.list_secrets()))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|s| s.starts_with(&*cur))
|
||||
.map(CompletionCandidate::new)
|
||||
.collect()
|
||||
}
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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;
|
||||
|
||||
struct DummyProvider;
|
||||
#[async_trait::async_trait]
|
||||
impl SecretProvider for DummyProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"Dummy"
|
||||
}
|
||||
async fn get_secret(&self, key: &str) -> Result<String> {
|
||||
Ok(format!("{}_VAL", key))
|
||||
}
|
||||
async fn set_secret(&self, _key: &str, _value: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_secret(&self, _key: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn sync(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_generate_files_secret_injections() {
|
||||
@@ -290,6 +341,7 @@ mod tests {
|
||||
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
provider: None,
|
||||
secrets: Some(vec!["testing/SOME-secret".to_string()]),
|
||||
files: Some(vec![file_path.clone()]),
|
||||
flag: None,
|
||||
@@ -309,6 +361,7 @@ mod tests {
|
||||
fn test_parse_args_insert_and_append() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("docker".into()),
|
||||
provider: None,
|
||||
secrets: Some(vec!["api_key".into()]),
|
||||
files: None,
|
||||
flag: Some("-e".into()),
|
||||
@@ -347,10 +400,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_wrap_and_run_command_no_profile() {
|
||||
let cfg = Config::default();
|
||||
let mut dummy = DummyProvider;
|
||||
let prov: &mut dyn SecretProvider = &mut dummy;
|
||||
let tokens = vec![OsString::from("echo"), OsString::from("hi")];
|
||||
let err = wrap_and_run_command(prov, &cfg, tokens, None, true)
|
||||
let err = wrap_and_run_command(None, &cfg, tokens, None, true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("No run profile found"));
|
||||
@@ -361,6 +412,7 @@ mod tests {
|
||||
// Create a config with a matching run profile for command "echo"
|
||||
let run_cfg = RunConfig {
|
||||
name: Some("echo".into()),
|
||||
provider: None,
|
||||
secrets: Some(vec!["api_key".into()]),
|
||||
files: None,
|
||||
flag: None,
|
||||
@@ -371,14 +423,128 @@ mod tests {
|
||||
run_configs: Some(vec![run_cfg]),
|
||||
..Config::default()
|
||||
};
|
||||
let mut dummy = DummyProvider;
|
||||
let prov: &mut dyn SecretProvider = &mut dummy;
|
||||
|
||||
// Capture stderr for dry_run preview
|
||||
let tokens = vec![OsString::from("echo"), OsString::from("hello")];
|
||||
// Best-effort: ensure function does not error under dry_run
|
||||
let res = wrap_and_run_command(prov, &cfg, tokens, None, true).await;
|
||||
assert!(res.is_ok());
|
||||
// Not asserting output text to keep test platform-agnostic
|
||||
let err = wrap_and_run_command(None, &cfg, tokens, None, true)
|
||||
.await
|
||||
.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") };
|
||||
}
|
||||
}
|
||||
|
||||
+15
-4
@@ -1,8 +1,12 @@
|
||||
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,
|
||||
};
|
||||
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};
|
||||
@@ -48,11 +52,11 @@ struct Cli {
|
||||
output: Option<OutputFormat>,
|
||||
|
||||
/// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local'))
|
||||
#[arg(long, value_enum, global = true, env = "GMAN_PROVIDER")]
|
||||
#[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
|
||||
#[arg(long, short)]
|
||||
#[arg(long, short, add = ArgValueCompleter::new(run_config_completer))]
|
||||
profile: Option<String>,
|
||||
|
||||
/// Output the command that will be run instead of executing it
|
||||
@@ -74,14 +78,17 @@ struct Cli {
|
||||
#[derive(Subcommand, Clone, Debug)]
|
||||
enum Commands {
|
||||
/// Add a secret to the configured secret provider
|
||||
#[clap(aliases = &["set", "create"])]
|
||||
Add {
|
||||
/// Name of the secret to store
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Decrypt a secret and print the plaintext
|
||||
#[clap(alias = "show")]
|
||||
Get {
|
||||
/// Name of the secret to retrieve
|
||||
#[arg(add = ArgValueCompleter::new(secrets_completer))]
|
||||
name: String,
|
||||
},
|
||||
|
||||
@@ -89,17 +96,21 @@ enum Commands {
|
||||
/// If a provider does not support updating secrets, this command will return an error.
|
||||
Update {
|
||||
/// Name of the secret to update
|
||||
#[arg(add = ArgValueCompleter::new(secrets_completer))]
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Delete a secret from the configured secret provider
|
||||
#[clap(aliases = &["remove", "rm"])]
|
||||
Delete {
|
||||
/// Name of the secret to delete
|
||||
#[arg(add = ArgValueCompleter::new(secrets_completer))]
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// List all secrets stored in the configured secret provider (if supported by the provider)
|
||||
/// If a provider does not support listing secrets, this command will return an error.
|
||||
#[clap(alias = "ls")]
|
||||
List {},
|
||||
|
||||
/// Sync secrets with remote storage (if supported by the provider)
|
||||
@@ -129,6 +140,7 @@ async fn main() -> Result<()> {
|
||||
panic::set_hook(Box::new(|info| {
|
||||
panic_hook(info);
|
||||
}));
|
||||
CompleteEnv::with_factory(Cli::command).complete();
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.show_log_path {
|
||||
@@ -253,8 +265,7 @@ async fn main() -> Result<()> {
|
||||
})?;
|
||||
}
|
||||
Commands::External(tokens) => {
|
||||
wrap_and_run_command(secrets_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();
|
||||
|
||||
+401
-2
@@ -11,6 +11,7 @@
|
||||
//!
|
||||
//! let rc = RunConfig{
|
||||
//! name: Some("echo".into()),
|
||||
//! provider: None,
|
||||
//! secrets: Some(vec!["api_key".into()]),
|
||||
//! files: None,
|
||||
//! flag: None,
|
||||
@@ -25,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;
|
||||
@@ -44,13 +46,19 @@ use validator::{Validate, ValidationError};
|
||||
#[validate(schema(function = "flags_or_files"))]
|
||||
pub struct RunConfig {
|
||||
#[validate(required)]
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub provider: Option<String>,
|
||||
#[validate(required)]
|
||||
pub secrets: Option<Vec<String>>,
|
||||
pub files: Option<Vec<PathBuf>>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub flag: Option<String>,
|
||||
#[validate(range(min = 1))]
|
||||
#[serde(default, deserialize_with = "deserialize_optional_usize_env_var")]
|
||||
pub flag_position: Option<usize>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub arg_format: Option<String>,
|
||||
}
|
||||
|
||||
@@ -161,6 +169,10 @@ impl ProviderConfig {
|
||||
debug!("Using Azure Key Vault provider");
|
||||
provider_def
|
||||
}
|
||||
SupportedProvider::Gopass { provider_def } => {
|
||||
debug!("Using Gopass provider");
|
||||
provider_def
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,6 +198,7 @@ impl ProviderConfig {
|
||||
#[validate(schema(function = "default_provider_exists"))]
|
||||
#[validate(schema(function = "providers_names_are_unique"))]
|
||||
pub struct Config {
|
||||
#[serde(deserialize_with = "deserialize_optional_env_var")]
|
||||
pub default_provider: Option<String>,
|
||||
#[validate(length(min = 1))]
|
||||
#[validate(nested)]
|
||||
@@ -325,7 +338,7 @@ pub fn load_config() -> Result<Config> {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Returns the configuration file path that `confy` will use for this app.
|
||||
/// 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");
|
||||
@@ -334,8 +347,394 @@ 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 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 {
|
||||
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: ®ex::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 indoc::indoc;
|
||||
use pretty_assertions::{assert_eq, assert_str_eq};
|
||||
use serde::Deserialize;
|
||||
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]
|
||||
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,3 +1,4 @@
|
||||
use crate::config::deserialize_optional_env_var;
|
||||
use crate::providers::SecretProvider;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
@@ -32,8 +33,10 @@ use validator::Validate;
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AwsSecretsManagerProvider {
|
||||
#[validate(required)]
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub aws_profile: Option<String>,
|
||||
#[validate(required)]
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub aws_region: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::config::deserialize_optional_env_var;
|
||||
use crate::providers::SecretProvider;
|
||||
use anyhow::{Context, Result};
|
||||
use azure_identity::DefaultAzureCredential;
|
||||
@@ -30,6 +31,7 @@ use validator::Validate;
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AzureKeyVaultProvider {
|
||||
#[validate(required)]
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub vault_name: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::config::deserialize_optional_env_var;
|
||||
use crate::providers::SecretProvider;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use gcloud_sdk::google::cloud::secretmanager::v1;
|
||||
@@ -39,6 +40,7 @@ type SecretsManagerClient = GoogleApi<SecretManagerServiceClient<GoogleAuthMiddl
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GcpSecretManagerProvider {
|
||||
#[validate(required)]
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub gcp_project_id: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
use crate::config::deserialize_optional_env_var;
|
||||
use crate::providers::{ENV_PATH, SecretProvider};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::skip_serializing_none;
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use validator::Validate;
|
||||
|
||||
#[skip_serializing_none]
|
||||
/// Gopass-based secret provider
|
||||
/// See [Gopass](https://gopass.pw/) for more information.
|
||||
///
|
||||
/// You must already have gopass installed and configured on your system.
|
||||
///
|
||||
/// This provider stores secrets in a gopass store. It requires
|
||||
/// an optional store name to be specified. If no store name is
|
||||
/// specified, the default store will be used.
|
||||
///
|
||||
/// Example
|
||||
/// ```no_run
|
||||
/// use gman::providers::gopass::GopassProvider;
|
||||
/// use gman::providers::{SecretProvider, SupportedProvider};
|
||||
/// use gman::config::Config;
|
||||
///
|
||||
/// let provider = GopassProvider::default();
|
||||
/// let _ = provider.set_secret("MY_SECRET", "value");
|
||||
/// ```
|
||||
#[derive(Debug, Default, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GopassProvider {
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub store: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SecretProvider for GopassProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"GopassProvider"
|
||||
}
|
||||
|
||||
async fn get_secret(&self, key: &str) -> Result<String> {
|
||||
ensure_gopass_installed()?;
|
||||
|
||||
let mut child = Command::new("gopass")
|
||||
.args(["show", "-yfon", key])
|
||||
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.context("Failed to spawn gopass command")?;
|
||||
|
||||
let mut output = String::new();
|
||||
child
|
||||
.stdout
|
||||
.as_mut()
|
||||
.expect("Failed to open gopass stdout")
|
||||
.read_to_string(&mut output)
|
||||
.context("Failed to read gopass output")?;
|
||||
|
||||
let status = child.wait().context("Failed to wait on gopass process")?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!("gopass command failed with status: {}", status));
|
||||
}
|
||||
|
||||
Ok(output.trim_end_matches(&['\r', '\n'][..]).to_string())
|
||||
}
|
||||
|
||||
async fn set_secret(&self, key: &str, value: &str) -> Result<()> {
|
||||
ensure_gopass_installed()?;
|
||||
|
||||
let mut child = Command::new("gopass")
|
||||
.args(["insert", "-f", key])
|
||||
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.context("Failed to spawn gopass command")?;
|
||||
|
||||
{
|
||||
let stdin = child.stdin.as_mut().expect("Failed to open gopass stdin");
|
||||
stdin
|
||||
.write_all(value.as_bytes())
|
||||
.context("Failed to write to gopass stdin")?;
|
||||
}
|
||||
|
||||
let status = child.wait().context("Failed to wait on gopass process")?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!("gopass command failed with status: {}", status));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_secret(&self, key: &str, value: &str) -> Result<()> {
|
||||
ensure_gopass_installed()?;
|
||||
|
||||
self.set_secret(key, value).await
|
||||
}
|
||||
|
||||
async fn delete_secret(&self, key: &str) -> Result<()> {
|
||||
ensure_gopass_installed()?;
|
||||
|
||||
let mut child = Command::new("gopass")
|
||||
.args(["rm", "-f", key])
|
||||
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.context("Failed to spawn gopass command")?;
|
||||
|
||||
let status = child.wait().context("Failed to wait on gopass process")?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!("gopass command failed with status: {}", status));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_secrets(&self) -> Result<Vec<String>> {
|
||||
ensure_gopass_installed()?;
|
||||
|
||||
let mut child = Command::new("gopass")
|
||||
.args(["ls", "-f"])
|
||||
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.context("Failed to spawn gopass command")?;
|
||||
|
||||
let mut output = String::new();
|
||||
child
|
||||
.stdout
|
||||
.as_mut()
|
||||
.expect("Failed to open gopass stdout")
|
||||
.read_to_string(&mut output)
|
||||
.context("Failed to read gopass output")?;
|
||||
|
||||
let status = child.wait().context("Failed to wait on gopass process")?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!("gopass command failed with status: {}", status));
|
||||
}
|
||||
|
||||
let secrets: Vec<String> = output
|
||||
.lines()
|
||||
.map(|line| line.trim().to_string())
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(secrets)
|
||||
}
|
||||
|
||||
async fn sync(&mut self) -> Result<()> {
|
||||
ensure_gopass_installed()?;
|
||||
let mut child = Command::new("gopass");
|
||||
child.arg("sync");
|
||||
|
||||
if let Some(store) = &self.store {
|
||||
child.args(["-s", store]);
|
||||
}
|
||||
|
||||
let status = child
|
||||
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.context("Failed to spawn gopass command")?
|
||||
.wait()
|
||||
.context("Failed to wait on gopass process")?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(anyhow!("gopass command failed with status: {}", status));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_gopass_installed() -> Result<()> {
|
||||
if which::which("gopass").is_err() {
|
||||
Err(anyhow!(
|
||||
"Gopass is not installed or not found in PATH. Please install Gopass from https://gopass.pw/"
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::config::deserialize_optional_env_var;
|
||||
use crate::config::deserialize_optional_pathbuf_env_var;
|
||||
use anyhow::{Context, anyhow, bail};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use std::collections::HashMap;
|
||||
@@ -50,14 +52,21 @@ use validator::Validate;
|
||||
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LocalProvider {
|
||||
#[serde(default, deserialize_with = "deserialize_optional_pathbuf_env_var")]
|
||||
pub password_file: Option<PathBuf>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub git_branch: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub git_remote_url: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub git_user_name: Option<String>,
|
||||
#[validate(email)]
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub git_user_email: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_pathbuf_env_var")]
|
||||
pub git_executable: Option<PathBuf>,
|
||||
#[serde(skip)]
|
||||
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
|
||||
pub runtime_provider_name: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
+16
-3
@@ -6,17 +6,24 @@ pub mod aws_secrets_manager;
|
||||
pub mod azure_key_vault;
|
||||
pub mod gcp_secret_manager;
|
||||
mod git_sync;
|
||||
pub mod gopass;
|
||||
pub mod local;
|
||||
|
||||
use crate::providers::gopass::GopassProvider;
|
||||
use crate::providers::local::LocalProvider;
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use aws_secrets_manager::AwsSecretsManagerProvider;
|
||||
use azure_key_vault::AzureKeyVaultProvider;
|
||||
use gcp_secret_manager::GcpSecretManagerProvider;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::{env, fmt};
|
||||
use validator::{Validate, ValidationErrors};
|
||||
|
||||
pub(in crate::providers) static ENV_PATH: Lazy<Result<String>> =
|
||||
Lazy::new(|| env::var("PATH").context("No PATH environment variable"));
|
||||
|
||||
/// A secret storage backend capable of CRUD, with optional
|
||||
/// update, listing, and sync support.
|
||||
#[async_trait::async_trait]
|
||||
@@ -63,7 +70,11 @@ pub enum SupportedProvider {
|
||||
},
|
||||
AzureKeyVault {
|
||||
#[serde(flatten)]
|
||||
provider_def: azure_key_vault::AzureKeyVaultProvider,
|
||||
provider_def: AzureKeyVaultProvider,
|
||||
},
|
||||
Gopass {
|
||||
#[serde(flatten)]
|
||||
provider_def: GopassProvider,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -74,6 +85,7 @@ impl Validate for SupportedProvider {
|
||||
SupportedProvider::AwsSecretsManager { provider_def } => provider_def.validate(),
|
||||
SupportedProvider::GcpSecretManager { provider_def } => provider_def.validate(),
|
||||
SupportedProvider::AzureKeyVault { provider_def } => provider_def.validate(),
|
||||
SupportedProvider::Gopass { provider_def } => provider_def.validate(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +105,7 @@ impl Display for SupportedProvider {
|
||||
SupportedProvider::AwsSecretsManager { .. } => write!(f, "aws_secrets_manager"),
|
||||
SupportedProvider::GcpSecretManager { .. } => write!(f, "gcp_secret_manager"),
|
||||
SupportedProvider::AzureKeyVault { .. } => write!(f, "azure_key_vault"),
|
||||
SupportedProvider::Gopass { .. } => write!(f, "gopass"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ mod tests {
|
||||
fn test_run_config_valid() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
provider: None,
|
||||
secrets: Some(vec!["secret1".to_string()]),
|
||||
flag: None,
|
||||
flag_position: None,
|
||||
@@ -23,6 +24,7 @@ mod tests {
|
||||
fn test_run_config_missing_name() {
|
||||
let run_config = RunConfig {
|
||||
name: None,
|
||||
provider: None,
|
||||
secrets: Some(vec!["secret1".to_string()]),
|
||||
flag: None,
|
||||
flag_position: None,
|
||||
@@ -37,6 +39,7 @@ mod tests {
|
||||
fn test_run_config_missing_secrets() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
provider: None,
|
||||
secrets: None,
|
||||
flag: None,
|
||||
flag_position: None,
|
||||
@@ -51,6 +54,7 @@ mod tests {
|
||||
fn test_run_config_invalid_flag_position() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
provider: None,
|
||||
secrets: Some(vec!["secret1".to_string()]),
|
||||
flag: Some("--test-flag".to_string()),
|
||||
flag_position: Some(0),
|
||||
@@ -65,6 +69,7 @@ mod tests {
|
||||
fn test_run_config_flags_or_none_all_some() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
provider: None,
|
||||
secrets: Some(vec!["secret1".to_string()]),
|
||||
flag: Some("--test-flag".to_string()),
|
||||
flag_position: Some(1),
|
||||
@@ -79,6 +84,7 @@ mod tests {
|
||||
fn test_run_config_flags_or_none_all_none() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
provider: None,
|
||||
secrets: Some(vec!["secret1".to_string()]),
|
||||
flag: None,
|
||||
flag_position: None,
|
||||
@@ -93,6 +99,7 @@ mod tests {
|
||||
fn test_run_config_flags_or_none_partial_some() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
provider: None,
|
||||
secrets: Some(vec!["secret1".to_string()]),
|
||||
flag: Some("--test-flag".to_string()),
|
||||
flag_position: None,
|
||||
@@ -107,6 +114,7 @@ mod tests {
|
||||
fn test_run_config_flags_or_none_missing_placeholder() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
provider: None,
|
||||
secrets: Some(vec!["secret1".to_string()]),
|
||||
flag: Some("--test-flag".to_string()),
|
||||
flag_position: Some(1),
|
||||
@@ -121,6 +129,7 @@ mod tests {
|
||||
fn test_run_config_flags_or_files_all_none() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
provider: None,
|
||||
secrets: Some(vec!["secret1".to_string()]),
|
||||
flag: None,
|
||||
flag_position: None,
|
||||
@@ -135,6 +144,7 @@ mod tests {
|
||||
fn test_run_config_flags_or_files_files_is_some() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
provider: None,
|
||||
secrets: Some(vec!["secret1".to_string()]),
|
||||
flag: None,
|
||||
flag_position: None,
|
||||
@@ -149,6 +159,7 @@ mod tests {
|
||||
fn test_run_config_flags_or_files_all_some() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
provider: None,
|
||||
secrets: Some(vec!["secret1".to_string()]),
|
||||
flag: Some("--test-flag".to_string()),
|
||||
flag_position: Some(1),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
use gman::config::{Config, ProviderConfig};
|
||||
use gman::providers::{SecretProvider, SupportedProvider};
|
||||
use pretty_assertions::{assert_eq, assert_str_eq};
|
||||
use validator::Validate;
|
||||
|
||||
#[test]
|
||||
fn test_gopass_supported_provider_display_and_validate_from_yaml() {
|
||||
// Build a SupportedProvider via YAML to avoid direct type import
|
||||
let yaml = r#"---
|
||||
type: gopass
|
||||
store: personal
|
||||
"#;
|
||||
|
||||
let sp: SupportedProvider = serde_yaml::from_str(yaml).expect("valid supported provider yaml");
|
||||
// Validate delegates to inner provider (no required fields)
|
||||
assert!(sp.validate().is_ok());
|
||||
// Display formatting for the enum variant
|
||||
assert_eq!(sp.to_string(), "gopass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_config_with_gopass_deserialize_and_extract() {
|
||||
// Minimal ProviderConfig YAML using the gopass variant
|
||||
let yaml = r#"---
|
||||
name: gopass
|
||||
type: gopass
|
||||
"#;
|
||||
|
||||
let pc: ProviderConfig = serde_yaml::from_str(yaml).expect("valid provider config yaml");
|
||||
// Gopass has no required fields, so validation should pass
|
||||
assert!(pc.validate().is_ok());
|
||||
|
||||
// Extract the provider and inspect its name via the trait
|
||||
let mut pc_owned = pc.clone();
|
||||
let provider: &mut dyn SecretProvider = pc_owned.extract_provider();
|
||||
assert_str_eq!(provider.name(), "GopassProvider");
|
||||
|
||||
// Round-trip through Config with default_provider
|
||||
let cfg_yaml = r#"---
|
||||
default_provider: gopass
|
||||
providers:
|
||||
- name: gopass
|
||||
type: gopass
|
||||
store: personal
|
||||
"#;
|
||||
let cfg: Config = serde_yaml::from_str(cfg_yaml).expect("valid config yaml");
|
||||
assert!(cfg.validate().is_ok());
|
||||
|
||||
let extracted = cfg
|
||||
.extract_provider_config(None)
|
||||
.expect("should find default provider");
|
||||
assert_eq!(extracted.name.as_deref(), Some("gopass"));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod aws_secrets_manager_tests;
|
||||
mod azure_key_vault_tests;
|
||||
mod gcp_secret_manager_tests;
|
||||
mod gopass_tests;
|
||||
mod local_tests;
|
||||
mod provider_tests;
|
||||
|
||||
Reference in New Issue
Block a user