Added support for multiple providers and wrote additional regression tests. Also fixed a bug with local synchronization with remote Git repositories when the CLI was just installed but the remote repo already exists with stuff in it.

This commit is contained in:
2025-09-11 15:07:16 -06:00
parent 0f5c28a040
commit a8d959dac3
19 changed files with 1155 additions and 239 deletions
Generated
+176
View File
@@ -119,6 +119,22 @@ dependencies = [
"password-hash",
]
[[package]]
name = "assert_cmd"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bd389a4b2970a01282ee455294913c0a43724daedcd1a24c3eb0ec1c1320b66"
dependencies = [
"anstyle",
"bstr",
"doc-comment",
"libc",
"predicates",
"predicates-core",
"predicates-tree",
"wait-timeout",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -152,6 +168,21 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "2.9.4"
@@ -176,6 +207,17 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bstr"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4"
dependencies = [
"memchr",
"regex-automata",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.19.0"
@@ -481,6 +523,12 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "difflib"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]]
name = "digest"
version = "0.10.7"
@@ -533,6 +581,12 @@ dependencies = [
"syn",
]
[[package]]
name = "doc-comment"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
[[package]]
name = "document-features"
version = "0.2.11"
@@ -582,6 +636,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d"
[[package]]
name = "float-cmp"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
dependencies = [
"num-traits",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -642,6 +705,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"assert_cmd",
"backtrace",
"base64",
"chacha20poly1305",
@@ -657,7 +721,9 @@ dependencies = [
"indoc",
"log",
"log4rs",
"predicates",
"pretty_assertions",
"proptest",
"regex",
"rpassword",
"secrecy",
@@ -912,6 +978,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.175"
@@ -1033,6 +1105,12 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6"
[[package]]
name = "normalize-line-endings"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
[[package]]
name = "num-conv"
version = "0.1.0"
@@ -1190,6 +1268,36 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "predicates"
version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
dependencies = [
"anstyle",
"difflib",
"float-cmp",
"normalize-line-endings",
"predicates-core",
"regex",
]
[[package]]
name = "predicates-core"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
[[package]]
name = "predicates-tree"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
dependencies = [
"predicates-core",
"termtree",
]
[[package]]
name = "pretty_assertions"
version = "1.4.1"
@@ -1231,6 +1339,32 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proptest"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f"
dependencies = [
"bit-set",
"bit-vec",
"bitflags",
"lazy_static",
"num-traits",
"rand",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-xml"
version = "0.38.3"
@@ -1293,6 +1427,15 @@ dependencies = [
"getrandom 0.3.3",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.3",
]
[[package]]
name = "redox_syscall"
version = "0.5.17"
@@ -1408,6 +1551,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-fork"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f"
dependencies = [
"fnv",
"quick-error",
"tempfile",
"wait-timeout",
]
[[package]]
name = "ryu"
version = "1.0.20"
@@ -1660,6 +1815,12 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "thiserror"
version = "2.0.16"
@@ -1772,6 +1933,12 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-ident"
version = "1.0.18"
@@ -1890,6 +2057,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
+3
View File
@@ -44,6 +44,9 @@ regex = "1.11.2"
[dev-dependencies]
pretty_assertions = "1.4.1"
tempfile = "3.10.1"
proptest = "1.5.0"
assert_cmd = "2.0.16"
predicates = "3.1.2"
[[bin]]
bench = false
+80 -28
View File
@@ -1,30 +1,25 @@
# gman - Universal Credential Manager
`gman` is a command-line tool designed to streamline credential and secret management for your scripts, automations, and
applications. It provides a single, secure interface to store, retrieve, and inject secrets, eliminating the need to
juggle different methods like configuration files or environment variables for each tool.
`gman` is a command-line tool for managing and injecting secrets for your scripts, automations, and applications.
It provides a single, secure interface to store, retrieve, and inject secrets so you can stop hand-rolling config
files or sprinkling environment variables everywhere.
## Overview
The core philosophy of `gman` is to act as a universal wrapper for any command that requires credentials. You can store
your secrets—like API tokens, passwords, or certificates—in an encrypted vault backed by various providers. Then, you
can either fetch them directly or, more powerfully, execute commands through `gman`, which securely injects the
necessary secrets as environment variables or command-line flags.
`gman` acts as a universal wrapper for any command that needs credentials. Store your secrets—API tokens, passwords,
certs—with a provider, then either fetch them directly or run your command through `gman` to inject what it needs as
environment variables, flags, or file content.
## Features
- **Secure, Encrypted Storage**: All secrets are stored in an encrypted state using strong cryptography.
- **Pluggable Providers**: Supports different backends for secret storage. The default is a local file-based system.
- **Git Synchronization**: The `local` provider can synchronize your encrypted secrets across multiple systems using a
private Git repository.
- **Seamless Command Wrapping**: Run any command through `gman` to automatically provide it with the secrets it needs
(e.g., `gman aws s3 ls`).
- **Customizable Run Profiles**: Define how secrets are passed to commands, either as environment variables (default) or
as specific command-line flags.
- **Secret Name Standardization**: Enforces `snake_case` for all secret names to ensure consistency.
- **Direct Secret Access**: Retrieve plaintext secrets directly when needed (e.g., `gman get my_api_key`).
- **Dry Run Mode**: Preview the command and the secrets that will be injected without actually executing it using the
`--dry-run` flag.
- Secure encryption for stored secrets
- Pluggable providers (local by default; more can be added)
- Git sync for local vaults to move secrets across machines
- Command wrapping to inject secrets for any program
- Customizable run profiles (env, flags, or files)
- Consistent secret naming: input is snake_case; injected as UPPER_SNAKE_CASE
- Direct retrieval via `gman get ...`
- Dry-run to preview wrapped commands and secret injection
## Example Use Cases
@@ -68,7 +63,7 @@ cargo install --locked gman
## Configuration
`gman` is configured via a YAML file located somewhere different for each OS:
`gman` reads a YAML configuration file located at an OS-specific path:
### Linux
```
@@ -87,23 +82,30 @@ $HOME/Library/Application Support/gman/config.yml
### Default Configuration
gman supports multiple providers. Select one as the default and then list provider configurations.
```yaml
---
default_provider: local
providers:
- name: local
provider: local
password_file: ~/.gman_password
# Optional Git sync settings for the 'local' provider
git_branch: null # Defaults to 'main'
git_remote_url: null # Required for Git sync
git_branch: main # Defaults to 'main'
git_remote_url: null # Set to enable Git sync (SSH or HTTPS)
git_user_name: null # Defaults to global git config user.name
git_user_email: null # Defaults to global git config user.email
git_executable: null # Defaults to 'git' in PATH
run_configs: null # List of run configurations (profiles)
# List of run configurations (profiles). See below.
run_configs: []
```
## Providers
`gman` supports multiple providers for secret storage. The default provider is `local`, which stores secrets in an
encrypted file on your filesystem. The following table shows the available and planned providers:
encrypted file on your filesystem. The CLI and config format are designed to be extensible so new providers can be
documented and added without breaking existing setups. The following table shows the available and planned providers:
**Key:**
@@ -142,9 +144,16 @@ For use across multiple systems, `gman` can sync with a remote Git repository.
**Important Notes for Git Sync:**
- You **must** create the remote repository on your Git provider (e.g., GitHub) *before* attempting to sync.
- The `git_remote_url` must be in SSH or HTTPS format (e.g., `git@github.com:your-user/your-repo.git`).
- First sync behavior:
- If the remote already has content, `gman sync` adopts the remote state and discards uncommitted local changes in the
vault directory to avoid merge conflicts.
- If the remote is empty, `gman sync` initializes the repository locally, creates the first commit, and pushes.
**Example `local` provider config for Git sync:**
```yaml
default_provider: local
providers:
- name: local
provider: local
git_branch: main
git_remote_url: "git@github.com:my-user/gman-secrets.git"
@@ -157,9 +166,9 @@ git_user_email: "your.email@example.com"
Run configurations (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are
supported:
1. [**Environment Variables** (default)](#basic-run-config-environment-variables)
2. [**Command-Line Flags**](#advanced-run-config-command-line-flags)
3. [**Files**](#advanced-run-config-files)
1. [**Environment Variables** (default)](#environment-variable-secret-injection)
2. [**Command-Line Flags**](#inject-secrets-via-command-line-flags)
3. [**Files**](#inject-secrets-into-files)
When you wrap a command with `gman` and don't specify a specific run configuration via `--profile`, `gman` will look for
a profile with a `name` matching `<command>`. If found, it injects the specified secrets. If no profile is found, `gman`
@@ -322,5 +331,48 @@ All secret names are automatically converted to `snake_case`.
# Output will show: aws -e AWS_ACCESS_KEY_ID=***** ... s3 ls
```
### Multiple Providers and Switching
You can define multiple providers—even multiple of the same type—and switch between them per command.
Example: two AWS Secrets Manager providers named `lab` and `prod`.
```yaml
default_provider: prod
providers:
- name: lab
provider: aws_secrets_manager
# Additional provider-specific settings (e.g., region, role_arn, profile)
# region: us-east-1
# role_arn: arn:aws:iam::111111111111:role/lab-access
- name: prod
provider: aws_secrets_manager
# region: us-east-1
# role_arn: arn:aws:iam::222222222222:role/prod-access
run_configs:
- name: aws
secrets:
- aws_access_key_id
- aws_secret_access_key
```
Switch providers on the fly using the provider name defined in `providers`:
```sh
# Use the default (prod)
gman aws s3 ls
# Explicitly use lab
gman --provider lab aws s3 ls
# Fetch a secret from prod
gman get my_api_key
# Fetch a secret from lab
gman --provider lab get my_api_key
```
## Creator
* [Alex Clarke](https://github.com/Dark-Alex-17)
+114 -12
View File
@@ -1,6 +1,6 @@
use crate::command::preview_command;
use anyhow::{Context, Result, anyhow};
use gman::config::{Config, RunConfig};
use anyhow::{anyhow, Context, Result};
use gman::config::{Config, ProviderConfig, RunConfig};
use gman::providers::SecretProvider;
use heck::ToSnakeCase;
use log::{debug, error};
@@ -13,9 +13,11 @@ use std::process::Command;
const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}";
const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}";
pub fn wrap_and_run_command(
secrets_provider: Box<dyn SecretProvider>,
config: &Config,
provider_config: &ProviderConfig,
tokens: Vec<OsString>,
profile_name: Option<String>,
dry_run: bool,
@@ -30,15 +32,17 @@ pub fn wrap_and_run_command(
.ok_or_else(|| anyhow!("failed to convert program name to string"))?
};
let run_config_opt = config.run_configs.as_ref().and_then(|configs| {
configs.iter().filter(|c| c.name.is_some()).find(|c| {
c.name.as_ref().expect("failed to unwrap run config name") == run_config_profile_name
})
configs
.iter()
.find(|c| c.name.as_deref() == Some(run_config_profile_name))
});
if let Some(run_cfg) = run_config_opt {
let secrets_result = run_cfg
.secrets
.as_ref()
.expect("no secrets configured for run profile")
.ok_or_else(|| {
anyhow!("No secrets configured for run profile '{run_config_profile_name}'")
})?
.iter()
.map(|key| {
let secret_name = key.to_snake_case().to_uppercase();
@@ -47,7 +51,7 @@ pub fn wrap_and_run_command(
run_config_profile_name
);
secrets_provider
.get_secret(config, key.to_snake_case().to_uppercase().as_str())
.get_secret(provider_config, key.to_snake_case().to_uppercase().as_str())
.ok()
.map_or_else(
|| {
@@ -157,6 +161,7 @@ pub fn wrap_and_run_command(
}
Ok(())
}
fn generate_files_secret_injections(
secrets: HashMap<String, String>,
run_config: &RunConfig,
@@ -189,22 +194,24 @@ fn generate_files_secret_injections(
}
Ok(results)
}
pub fn run_cmd(cmd: &mut Command, dry_run: bool) -> Result<()> {
if dry_run {
eprintln!("Command to be executed: {}", preview_command(cmd));
println!("Command to be executed: {}", preview_command(cmd));
} else {
cmd.status()
.with_context(|| format!("failed to execute command '{:?}'", cmd))?;
}
Ok(())
}
pub fn parse_args(
args: &[OsString],
run_config: &RunConfig,
secrets: HashMap<String, String>,
dry_run: bool,
) -> Result<Vec<OsString>> {
let args = args.to_vec();
let mut args = args.to_vec();
let flag = run_config
.flag
.as_ref()
@@ -216,7 +223,6 @@ pub fn parse_args(
.arg_format
.as_ref()
.ok_or_else(|| anyhow!("arg_format must be set if flag is set"))?;
let mut args = args.to_vec();
if flag_position > args.len() {
secrets.iter().for_each(|(k, v)| {
let v = if dry_run { "*****" } else { v };
@@ -246,10 +252,31 @@ pub fn parse_args(
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::generate_files_secret_injections;
use gman::config::RunConfig;
use gman::config::{Config, ProviderConfig, RunConfig};
use pretty_assertions::{assert_eq, assert_str_eq};
use std::collections::HashMap;
use std::ffi::OsString;
struct DummyProvider;
impl SecretProvider for DummyProvider {
fn name(&self) -> &'static str {
"Dummy"
}
fn get_secret(&self, _config: &ProviderConfig, key: &str) -> Result<String> {
Ok(format!("{}_VAL", key))
}
fn set_secret(&self, _config: &ProviderConfig, _key: &str, _value: &str) -> Result<()> {
Ok(())
}
fn delete_secret(&self, _key: &str) -> Result<()> {
Ok(())
}
fn sync(&self, _config: &mut ProviderConfig) -> Result<()> {
Ok(())
}
}
#[test]
fn test_generate_files_secret_injections() {
@@ -257,7 +284,7 @@ mod tests {
secrets.insert("SECRET1".to_string(), "value1".to_string());
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "{{secret1}}").unwrap();
fs::write(&file_path, "{{secret1}}").unwrap();
let run_config = RunConfig {
name: Some("test".to_string()),
@@ -275,4 +302,79 @@ mod tests {
assert_str_eq!(result[0].1, "{{secret1}}");
assert_str_eq!(result[0].2, "value1");
}
#[test]
fn test_parse_args_insert_and_append() {
let run_config = RunConfig {
name: Some("docker".into()),
secrets: Some(vec!["api_key".into()]),
files: None,
flag: Some("-e".into()),
flag_position: Some(1),
arg_format: Some("{{key}}={{value}}".into()),
};
let mut secrets = HashMap::new();
secrets.insert("API_KEY".into(), "xyz".into());
// Insert at position
let args = vec![OsString::from("run"), OsString::from("image")];
let out = parse_args(&args, &run_config, secrets.clone(), true).unwrap();
assert_eq!(
out,
vec!["run", "-e", "API_KEY=*****", "image"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
// Append when position beyond len
let run_config2 = RunConfig {
flag_position: Some(99),
..run_config.clone()
};
let out2 = parse_args(&args, &run_config2, secrets, true).unwrap();
assert_eq!(
out2,
vec!["run", "image", "-e", "API_KEY=*****"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
);
}
#[test]
fn test_wrap_and_run_command_no_profile() {
let cfg = Config::default();
let provider_cfg = ProviderConfig::default();
let prov: Box<dyn SecretProvider> = Box::new(DummyProvider);
let tokens = vec![OsString::from("echo"), OsString::from("hi")];
let err = wrap_and_run_command(prov, &cfg, &provider_cfg, tokens, None, true).unwrap_err();
assert!(err.to_string().contains("No run profile found"));
}
#[test]
fn test_wrap_and_run_command_env_injection_dry_run() {
// Create a config with a matching run profile for command "echo"
let run_cfg = RunConfig {
name: Some("echo".into()),
secrets: Some(vec!["api_key".into()]),
files: None,
flag: None,
flag_position: None,
arg_format: None,
};
let cfg = Config {
run_configs: Some(vec![run_cfg]),
..Config::default()
};
let provider_cfg = ProviderConfig::default();
let prov: Box<dyn SecretProvider> = Box::new(DummyProvider);
// 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, &provider_cfg, tokens, None, true);
assert!(res.is_ok());
// Not asserting output text to keep test platform-agnostic
}
}
+20 -44
View File
@@ -1,22 +1,19 @@
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 std::ffi::OsString;
use anyhow::{Context, Result};
use clap::Subcommand;
use crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode};
use gman::config::Config;
use gman::providers::SupportedProvider;
use gman::providers::local::LocalProvider;
use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen};
use gman::config::load_config;
use heck::ToSnakeCase;
use std::io::{self, IsTerminal, Read, Write};
use std::panic::PanicHookInfo;
use crate::cli::wrap_and_run_command;
use std::panic;
use validator::Validate;
mod cli;
mod command;
@@ -28,20 +25,6 @@ enum OutputFormat {
Json,
}
#[derive(Debug, Clone, ValueEnum)]
#[clap(rename_all = "lower")]
pub enum ProviderKind {
Local,
}
impl From<ProviderKind> for SupportedProvider {
fn from(k: ProviderKind) -> Self {
match k {
ProviderKind::Local => SupportedProvider::Local(LocalProvider),
}
}
}
#[derive(Debug, Parser)]
#[command(
name = crate_name!(),
@@ -61,9 +44,9 @@ struct Cli {
#[arg(short, long, value_enum)]
output: Option<OutputFormat>,
/// Specify the secret provider to use (defaults to 'provider' in config or 'local')
/// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local'))
#[arg(long, value_enum)]
provider: Option<ProviderKind>,
provider: Option<String>,
/// Specify a run profile to use when wrapping a command
#[arg(long, short)]
@@ -130,8 +113,9 @@ fn main() -> Result<()> {
panic_hook(info);
}));
let cli = Cli::parse();
let mut config = load_config(&cli)?;
let secrets_provider = config.extract_provider();
let config = load_config()?;
let mut provider_config = config.extract_provider_config(cli.provider.clone())?;
let secrets_provider = provider_config.extract_provider();
match cli.command {
Commands::Add { name } => {
@@ -139,7 +123,7 @@ fn main() -> Result<()> {
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider
.set_secret(&config, &snake_case_name, plaintext.trim_end())
.set_secret(&provider_config, &snake_case_name, plaintext.trim_end())
.map(|_| match cli.output {
Some(_) => (),
None => println!("✓ Secret '{snake_case_name}' added to the vault."),
@@ -148,7 +132,7 @@ fn main() -> Result<()> {
Commands::Get { name } => {
let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider
.get_secret(&config, &snake_case_name)
.get_secret(&provider_config, &snake_case_name)
.map(|secret| match cli.output {
Some(OutputFormat::Json) => {
let json_output = serde_json::json!({
@@ -170,7 +154,7 @@ fn main() -> Result<()> {
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider
.update_secret(&config, &snake_case_name, plaintext.trim_end())
.update_secret(&provider_config, &snake_case_name, plaintext.trim_end())
.map(|_| match cli.output {
Some(_) => (),
None => println!("✓ Secret '{snake_case_name}' updated in the vault."),
@@ -211,14 +195,21 @@ fn main() -> Result<()> {
}
}
Commands::Sync {} => {
secrets_provider.sync(&mut config).map(|_| {
secrets_provider.sync(&mut provider_config).map(|_| {
if cli.output.is_none() {
println!("✓ Secrets synchronized with remote")
}
})?;
}
Commands::External(tokens) => {
wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run)?;
wrap_and_run_command(
secrets_provider,
&config,
&provider_config,
tokens,
cli.profile,
cli.dry_run,
)?;
}
Commands::Completions { shell } => {
let mut cmd = Cli::command();
@@ -230,21 +221,6 @@ fn main() -> Result<()> {
Ok(())
}
fn load_config(cli: &Cli) -> Result<Config> {
let mut config: Config = confy::load("gman", "config")?;
config.validate()?;
if let Some(local_password_file) = Config::local_provider_password_file() {
config.password_file = Some(local_password_file);
}
if let Some(provider_kind) = &cli.provider {
let provider: SupportedProvider = provider_kind.clone().into();
config.provider = provider;
}
Ok(config)
}
fn read_all_stdin() -> Result<String> {
if io::stdin().is_terminal() {
#[cfg(not(windows))]
+158 -14
View File
@@ -1,12 +1,41 @@
//! Application configuration and run-profile validation.
//!
//! The [`Config`] type captures global settings such as which secret provider
//! to use and Git sync preferences. The [`RunConfig`] type describes how to
//! inject secrets when wrapping a command.
//!
//! Example: validate a minimal run profile
//! ```
//! use gman::config::RunConfig;
//! use validator::Validate;
//!
//! let rc = RunConfig{
//! name: Some("echo".into()),
//! secrets: Some(vec!["api_key".into()]),
//! files: None,
//! flag: None,
//! flag_position: None,
//! arg_format: None,
//! };
//! rc.validate().unwrap();
//! ```
use crate::providers::local::LocalProvider;
use crate::providers::{SecretProvider, SupportedProvider};
use anyhow::Result;
use log::debug;
use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as;
use serde_with::{skip_serializing_none, DisplayFromStr};
use std::borrow::Cow;
use std::path::PathBuf;
use validator::{Validate, ValidationError};
#[skip_serializing_none]
/// Describe how to inject secrets for a named command profile.
///
/// A valid profile either defines no flag/file settings or provides a complete
/// set of `flag`, `flag_position`, and `arg_format`. Additionally, the flag
/// mode and the fileinjection mode are mutually exclusive.
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
#[validate(schema(function = "flags_or_none", skip_on_field_errors = false))]
#[validate(schema(function = "flags_or_files"))]
@@ -68,8 +97,24 @@ fn flags_or_files(run_config: &RunConfig) -> Result<(), ValidationError> {
}
#[serde_as]
#[skip_serializing_none]
/// Configuration for a secret provider.
///
/// Example: create a local provider config and validate it
/// ```
/// use gman::config::ProviderConfig;
/// use gman::providers::SupportedProvider;
/// use gman::providers::local::LocalProvider;
/// use validator::Validate;
///
/// let provider = SupportedProvider::Local(LocalProvider);
/// let provider_config = ProviderConfig { provider, ..Default::default() };
/// provider_config.validate().unwrap();
/// ```
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
pub struct Config {
pub struct ProviderConfig {
#[validate(required)]
pub name: Option<String>,
#[serde_as(as = "DisplayFromStr")]
pub provider: SupportedProvider,
pub password_file: Option<PathBuf>,
@@ -79,26 +124,31 @@ pub struct Config {
#[validate(email)]
pub git_user_email: Option<String>,
pub git_executable: Option<PathBuf>,
#[validate(nested)]
pub run_configs: Option<Vec<RunConfig>>,
}
impl Default for Config {
impl Default for ProviderConfig {
fn default() -> Self {
Self {
provider: SupportedProvider::Local(Default::default()),
name: Some("local".into()),
provider: SupportedProvider::Local(LocalProvider),
password_file: Config::local_provider_password_file(),
git_branch: Some("main".into()),
git_remote_url: None,
git_user_name: None,
git_user_email: None,
git_executable: None,
run_configs: None,
}
}
}
impl Config {
impl ProviderConfig {
/// Instantiate the configured secret provider.
///
/// ```no_run
/// # use gman::config::ProviderConfig;
/// let provider = ProviderConfig::default().extract_provider();
/// println!("using provider: {}", provider.name());
/// ```
pub fn extract_provider(&self) -> Box<dyn SecretProvider> {
match &self.provider {
SupportedProvider::Local(p) => {
@@ -107,15 +157,109 @@ impl Config {
}
}
}
}
#[serde_as]
#[skip_serializing_none]
/// Global configuration for the library and CLI.
///
/// Example: pick a provider and validate the configuration
/// ```
/// use gman::config::Config;
/// use gman::config::ProviderConfig;
/// use gman::providers::SupportedProvider;
/// use gman::providers::local::LocalProvider;
/// use validator::Validate;
///
/// let provider = SupportedProvider::Local(LocalProvider);
/// let provider_config = ProviderConfig { provider, ..Default::default() };
/// let cfg = Config{ providers: vec![provider_config], ..Default::default() };
/// cfg.validate().unwrap();
/// ```
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
#[validate(schema(function = "default_provider_exists"))]
pub struct Config {
pub default_provider: Option<String>,
#[validate(length(min = 1))]
#[validate(nested)]
pub providers: Vec<ProviderConfig>,
#[validate(nested)]
pub run_configs: Option<Vec<RunConfig>>,
}
fn default_provider_exists(config: &Config) -> Result<(), ValidationError> {
if let Some(default) = &config.default_provider {
if config.providers.iter().any(|p| p.name.as_deref() == Some(default)) {
Ok(())
} else {
let mut err = ValidationError::new("default_provider_missing");
err.message = Some(Cow::Borrowed(
"The default_provider does not match any configured provider names",
));
Err(err)
}
} else {
Ok(())
}
}
impl Default for Config {
fn default() -> Self {
Self {
default_provider: Some("local".into()),
providers: vec![ProviderConfig::default()],
run_configs: None,
}
}
}
impl Config {
/// Instantiate the configured secret provider.
///
/// ```no_run
/// # use gman::config::Config;
/// let provider_config = Config::default().extract_provider_config(None);
/// println!("using provider config: {}", provider_config.unwrap().name);
/// ```
pub fn extract_provider_config(&self, provider_name: Option<String>) -> Result<ProviderConfig> {
let name = provider_name
.or_else(|| self.default_provider.clone())
.unwrap_or_else(|| "local".into());
self.providers
.iter()
.find(|p| p.name.as_deref() == Some(&name))
.cloned()
.ok_or_else(|| anyhow::anyhow!("No provider configuration found for '{}'", name))
}
/// Discover the default password file for the local provider.
///
/// On most systems this resolves to `~/.gman_password` when the file
/// exists, otherwise `None`.
pub fn local_provider_password_file() -> Option<PathBuf> {
let mut path = dirs::home_dir().map(|p| p.join(".gman_password"));
if let Some(p) = &path
&& !p.exists()
{
path = None;
let candidate = dirs::home_dir().map(|p| p.join(".gman_password"));
match candidate {
Some(p) if p.exists() => Some(p),
_ => None,
}
}
}
path
pub fn load_config() -> Result<Config> {
let mut config: Config = confy::load("gman", "config")?;
config.validate()?;
config
.providers
.iter_mut()
.filter(|p| matches!(p.provider, SupportedProvider::Local(_)))
.for_each(|p| {
if p.password_file.is_none()
&& let Some(local_password_file) = Config::local_provider_password_file()
{
p.password_file = Some(local_password_file);
}
});
Ok(config)
}
+60 -7
View File
@@ -1,16 +1,40 @@
use anyhow::{Context, Result, anyhow, bail};
//! Gman core library
//!
//! This crate provides two layers:
//! - A small crypto helper API for envelope encrypting/decrypting strings.
//! - Public modules for configuration and secret providers used by the CLI.
//!
//! Quick start for the crypto helpers:
//!
//! ```
//! use gman::{encrypt_string, decrypt_string};
//! use secrecy::SecretString;
//!
//! let password = SecretString::new("correct horse battery staple".into());
//! let ciphertext = encrypt_string(password.clone(), "swordfish").unwrap();
//! let plaintext = decrypt_string(password, &ciphertext).unwrap();
//!
//! assert_eq!(plaintext, "swordfish");
//! ```
//!
//! The `config` and `providers` modules power the CLI. They can be embedded
//! in other programs, but many functions interact with the user or the
//! filesystem. Prefer `no_run` doctests for those.
use anyhow::{anyhow, bail, Context, Result};
use argon2::{
password_hash::{rand_core::RngCore, SaltString},
Algorithm, Argon2, Params, Version,
password_hash::{SaltString, rand_core::RngCore},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use chacha20poly1305::{
Key, XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, OsRng},
Key, XChaCha20Poly1305, XNonce,
};
use secrecy::{ExposeSecret, SecretString};
use zeroize::Zeroize;
/// Configuration structures and helpers used by the CLI and library.
pub mod config;
/// Secret provider trait and implementations.
pub mod providers;
pub(crate) const HEADER: &str = "$VAULT";
@@ -35,12 +59,26 @@ fn derive_key(password: &SecretString, salt: &[u8]) -> Result<Key> {
.hash_password_into(password.expose_secret().as_bytes(), salt, &mut key_bytes)
.map_err(|e| anyhow!("argon2 into error: {:?}", e))?;
let cloned_key_bytes = key_bytes;
let key = Key::from_slice(&cloned_key_bytes);
let key = *Key::from_slice(&key_bytes);
key_bytes.zeroize();
Ok(*key)
Ok(key)
}
/// Encrypt a UTF8 string using a password and return a portable envelope.
///
/// The returned value is a semicolonseparated envelope containing metadata
/// (header, version, KDF params) and base64 encoded salt, nonce and
/// ciphertext. It is safe to store in configuration files.
///
/// Example
/// ```
/// use gman::encrypt_string;
/// use secrecy::SecretString;
///
/// let pw = SecretString::new("password".into());
/// let env = encrypt_string(pw, "hello").unwrap();
/// assert!(env.starts_with("$VAULT;v1;argon2id;"));
/// ```
pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Result<String> {
let password = password.into();
@@ -87,6 +125,21 @@ pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Res
Ok(env)
}
/// Decrypt an envelope produced by [`encrypt_string`].
///
/// Returns the original plaintext on success or an error if the password is
/// wrong, the envelope was tampered with, or the input is malformed.
///
/// Example
/// ```
/// use gman::{encrypt_string, decrypt_string};
/// use secrecy::SecretString;
///
/// let pw = SecretString::new("pw".into());
/// let env = encrypt_string(pw.clone(), "top secret").unwrap();
/// let pt = decrypt_string(pw, &env).unwrap();
/// assert_eq!(pt, "top secret");
/// ```
pub fn decrypt_string(password: impl Into<SecretString>, envelope: &str) -> Result<String> {
let password = password.into();
+95 -4
View File
@@ -66,10 +66,16 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
checkout_branch(&git, &repo_dir, branch)?;
set_origin(&git, &repo_dir, remote_url)?;
stage_all(&git, &repo_dir)?;
// Always align local with remote before staging/committing. For a fresh
// repo where the remote already has content, we intentionally discard any
// local working tree changes and take the remote state to avoid merge
// conflicts on first sync.
fetch_and_pull(&git, &repo_dir, branch)?;
// Stage and commit any subsequent local changes after aligning with remote
// so we don't merge uncommitted local state.
stage_all(&git, &repo_dir)?;
commit_now(&git, &repo_dir, &commit_message)?;
run_git(
@@ -228,17 +234,51 @@ fn stage_all(git: &Path, repo: &Path) -> Result<()> {
}
fn fetch_and_pull(git: &Path, repo: &Path, branch: &str) -> Result<()> {
run_git(git, repo, &["fetch", "origin", branch])
// Fetch all refs from origin (safe even if branch doesn't exist remotely)
run_git(git, repo, &["fetch", "origin", "--prune"])
.with_context(|| "Failed to fetch changes from remote")?;
let origin_ref = format!("origin/{branch}");
let remote_has_branch = has_remote_branch(git, repo, branch);
// If the repo has no commits yet, prefer remote state and discard local
// if the remote branch exists. Otherwise, keep local state and allow an
// initial commit to be created and pushed.
if !has_head(git, repo) {
if remote_has_branch {
run_git(git, repo, &["checkout", "-f", "-B", branch, &origin_ref])
.with_context(|| "Failed to checkout remote branch over local state")?;
run_git(git, repo, &["reset", "--hard", &origin_ref])
.with_context(|| "Failed to hard reset to remote branch")?;
run_git(git, repo, &["clean", "-fd"]).with_context(|| "Failed to clean untracked files")?;
}
return Ok(());
}
// If we have local history and the remote branch exists, fast-forward.
if remote_has_branch {
run_git(
git,
repo,
&["merge", "--ff-only", &format!("origin/{branch}")],
&["merge", "--ff-only", &origin_ref],
)
.with_context(|| "Failed to merge remote changes")?;
}
Ok(())
}
fn has_remote_branch(git: &Path, repo: &Path, branch: &str) -> bool {
Command::new(git)
.arg("-C")
.arg(repo)
.args(["show-ref", "--verify", "--quiet", &format!("refs/remotes/origin/{}", branch)])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn has_head(git: &Path, repo: &Path) -> bool {
Command::new(git)
.arg("-C")
@@ -280,3 +320,54 @@ fn commit_now(git: &Path, repo: &Path, msg: &str) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_opts_validation_ok() {
let remote = Some("git@github.com:user/repo.git".to_string());
let branch = Some("main".to_string());
let opts = SyncOpts {
remote_url: &remote,
branch: &branch,
user_name: &None,
user_email: &None,
git_executable: &None,
};
assert!(opts.validate().is_ok());
}
#[test]
fn sync_opts_validation_missing_fields() {
let remote = None;
let branch = None;
let opts = SyncOpts {
remote_url: &remote,
branch: &branch,
user_name: &None,
user_email: &None,
git_executable: &None,
};
assert!(opts.validate().is_err());
}
#[test]
fn resolve_git_prefers_override_and_env() {
// Override path wins
let override_path = Some(PathBuf::from("/custom/git"));
let got = resolve_git(override_path.as_ref()).unwrap();
assert_eq!(got, PathBuf::from("/custom/git"));
// If no override, env var is used
unsafe {
env::set_var("GIT_EXECUTABLE", "/env/git");
}
let got_env = resolve_git(None).unwrap();
assert_eq!(got_env, PathBuf::from("/env/git"));
unsafe {
env::remove_var("GIT_EXECUTABLE");
}
}
}
+58 -16
View File
@@ -1,29 +1,30 @@
use anyhow::{Context, anyhow, bail};
use anyhow::{anyhow, bail, Context};
use secrecy::{ExposeSecret, SecretString};
use std::collections::HashMap;
use std::fs;
use zeroize::Zeroize;
use crate::config::Config;
use crate::config::ProviderConfig;
use crate::providers::git_sync::{sync_and_push, SyncOpts};
use crate::providers::SecretProvider;
use crate::providers::git_sync::{SyncOpts, sync_and_push};
use crate::{
ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION,
};
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;
use theme::ColorfulTheme;
use validator::Validate;
/// Configuration for the local file-based provider.
#[derive(Debug, Clone)]
pub struct LocalProviderConfig {
pub vault_path: String,
@@ -40,6 +41,25 @@ impl Default for LocalProviderConfig {
}
}
/// File-based vault provider with optional Git sync.
///
/// This provider stores encrypted envelopes in a per-user configuration
/// directory via `confy`. A password is obtained from a configured password
/// file or via an interactive prompt.
///
/// Example
/// ```no_run
/// use gman::providers::local::LocalProvider;
/// use gman::providers::SecretProvider;
/// use gman::config::Config;
///
/// let provider = LocalProvider;
/// let cfg = Config::default();
/// // Will prompt for a password when reading/writing secrets unless a
/// // password file is configured.
/// // provider.set_secret(&cfg, "MY_SECRET", "value")?;
/// # Ok::<(), anyhow::Error>(())
/// ```
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
pub struct LocalProvider;
@@ -48,7 +68,7 @@ impl SecretProvider for LocalProvider {
"LocalProvider"
}
fn get_secret(&self, config: &Config, key: &str) -> Result<String> {
fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result<String> {
let vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
let envelope = vault
.get(key)
@@ -61,7 +81,7 @@ impl SecretProvider for LocalProvider {
Ok(plaintext)
}
fn set_secret(&self, config: &Config, key: &str, value: &str) -> Result<()> {
fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> {
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
if vault.contains_key(key) {
error!(
@@ -79,7 +99,7 @@ impl SecretProvider for LocalProvider {
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault")
}
fn update_secret(&self, config: &Config, key: &str, value: &str) -> Result<()> {
fn update_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> {
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
let password = get_password(config)?;
@@ -119,7 +139,7 @@ impl SecretProvider for LocalProvider {
Ok(keys)
}
fn sync(&self, config: &mut Config) -> Result<()> {
fn sync(&self, config: &mut ProviderConfig) -> Result<()> {
let mut config_changed = false;
if config.git_branch.is_none() {
@@ -139,9 +159,9 @@ impl SecretProvider for LocalProvider {
let remote: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter remote git URL to sync with")
.validate_with(|s: &String| {
Config {
ProviderConfig {
git_remote_url: Some(s.clone()),
..Config::default()
..ProviderConfig::default()
}
.validate()
.map(|_| ())
@@ -314,7 +334,7 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> {
Ok(s)
}
fn get_password(config: &Config) -> Result<SecretString> {
fn get_password(config: &ProviderConfig) -> Result<SecretString> {
if let Some(password_file) = &config.password_file {
let password = SecretString::new(
fs::read_to_string(password_file)
@@ -333,10 +353,10 @@ fn get_password(config: &Config) -> Result<SecretString> {
#[cfg(test)]
mod tests {
use crate::derive_key;
use crate::providers::local::derive_key_with_params;
use super::*;
use pretty_assertions::assert_eq;
use secrecy::SecretString;
use secrecy::{ExposeSecret, SecretString};
use tempfile::tempdir;
#[test]
fn test_derive_key() {
@@ -353,4 +373,26 @@ mod tests {
let key = derive_key_with_params(&password, &salt, 10, 1, 1).unwrap();
assert_eq!(key.as_slice().len(), 32);
}
#[test]
fn crypto_roundtrip_local_impl() {
let pw = SecretString::new("pw".into());
let msg = "hello world";
let env = encrypt_string(&pw, msg).unwrap();
let out = decrypt_string(&pw, &env).unwrap();
assert_eq!(out, msg);
}
#[test]
fn get_password_reads_password_file() {
let dir = tempdir().unwrap();
let file = dir.path().join("pw.txt");
fs::write(&file, "secretpw\n").unwrap();
let cfg = ProviderConfig {
password_file: Some(file),
..ProviderConfig::default()
};
let pw = get_password(&cfg).unwrap();
assert_eq!(pw.expose_secret(), "secretpw");
}
}
+29 -6
View File
@@ -1,19 +1,34 @@
//! Secret provider trait and registry.
//!
//! Implementations provide storage/backends for secrets and a common
//! interface used by the CLI.
//!
//! Selecting a provider from a string:
//! ```
//! use std::str::FromStr;
//! use gman::providers::SupportedProvider;
//!
//! let p = SupportedProvider::from_str("local").unwrap();
//! assert_eq!(p.to_string(), "local");
//! ```
mod git_sync;
pub mod local;
use crate::config::Config;
use crate::config::ProviderConfig;
use crate::providers::local::LocalProvider;
use anyhow::{Result, anyhow};
use anyhow::{anyhow, Result};
use serde::Deserialize;
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use thiserror::Error;
/// A secret storage backend capable of CRUD and sync, with optional
/// update and listing
pub trait SecretProvider {
fn name(&self) -> &'static str;
fn get_secret(&self, config: &Config, key: &str) -> Result<String>;
fn set_secret(&self, config: &Config, key: &str, value: &str) -> Result<()>;
fn update_secret(&self, _config: &Config, _key: &str, _value: &str) -> Result<()> {
fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result<String>;
fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()>;
fn update_secret(&self, _config: &ProviderConfig, _key: &str, _value: &str) -> Result<()> {
Err(anyhow!(
"update secret not supported for provider {}",
self.name()
@@ -26,20 +41,28 @@ pub trait SecretProvider {
self.name()
))
}
fn sync(&self, config: &mut Config) -> Result<()>;
fn sync(&self, config: &mut ProviderConfig) -> Result<()>;
}
/// Errors when parsing a provider identifier.
#[derive(Debug, Error)]
pub enum ParseProviderError {
#[error("unsupported provider '{0}'")]
Unsupported(String),
}
/// Registry of built-in providers.
#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)]
pub enum SupportedProvider {
Local(LocalProvider),
}
impl Default for SupportedProvider {
fn default() -> Self {
SupportedProvider::Local(LocalProvider)
}
}
impl FromStr for SupportedProvider {
type Err = ParseProviderError;
+198
View File
@@ -0,0 +1,198 @@
use assert_cmd::prelude::*;
use predicates::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::TempDir;
fn setup_env() -> (TempDir, PathBuf, PathBuf) {
let td = tempfile::tempdir().expect("tempdir");
let cfg_home = td.path().join("config");
let cache_home = td.path().join("cache");
let data_home = td.path().join("data");
fs::create_dir_all(&cfg_home).unwrap();
fs::create_dir_all(&cache_home).unwrap();
fs::create_dir_all(&data_home).unwrap();
(td, cfg_home, cache_home)
}
fn write_yaml_config(xdg_config_home: &Path, password_file: &Path, run_profile: Option<&str>) {
let app_dir = xdg_config_home.join("gman");
fs::create_dir_all(&app_dir).unwrap();
let cfg = if let Some(profile) = run_profile {
format!(
r#"default_provider: local
providers:
- name: local
provider: local
password_file: {}
run_configs:
- name: {}
secrets: ["api_key"]
"#,
password_file.display(),
profile
)
} else {
format!(
r#"default_provider: local
providers:
- name: local
provider: local
password_file: {}
"#,
password_file.display()
)
};
// Confy with yaml feature typically uses .yml; write both to be safe.
fs::write(app_dir.join("config.yml"), &cfg).unwrap();
fs::write(app_dir.join("config.yaml"), &cfg).unwrap();
}
#[test]
fn cli_shows_help() {
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)
.arg("--help");
cmd.assert()
.success()
.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();
let pw_file = td.path().join("pw.txt");
fs::write(&pw_file, b"testpw\n").unwrap();
write_yaml_config(&xdg_cfg, &pw_file, None);
// add
let mut add = Command::cargo_bin("gman").unwrap();
add.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.stdin(Stdio::piped())
.args(["add", "my_api_key"]);
let mut child = add.spawn().unwrap();
use std::io::Write as _;
child
.stdin
.as_mut()
.unwrap()
.write_all(b"super_secret\n")
.unwrap();
let add_out = child.wait_with_output().unwrap();
assert!(add_out.status.success());
// get (text)
let mut get = Command::cargo_bin("gman").unwrap();
get.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.args(["get", "my_api_key"]);
get.assert()
.success()
.stdout(predicate::str::contains("super_secret"));
// get as JSON
let mut get_json = Command::cargo_bin("gman").unwrap();
get_json
.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.args(["--output", "json", "get", "my_api_key"]);
get_json.assert().success().stdout(
predicate::str::contains("MY_API_KEY").and(predicate::str::contains("super_secret")),
);
// list
let mut list = Command::cargo_bin("gman").unwrap();
list.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.arg("list");
list.assert()
.success()
.stdout(predicate::str::contains("MY_API_KEY"));
// update
let mut update = Command::cargo_bin("gman").unwrap();
update
.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.stdin(Stdio::piped())
.args(["update", "my_api_key"]);
let mut child = update.spawn().unwrap();
child
.stdin
.as_mut()
.unwrap()
.write_all(b"new_val\n")
.unwrap();
let upd_out = child.wait_with_output().unwrap();
assert!(upd_out.status.success());
// get again
let mut get2 = Command::cargo_bin("gman").unwrap();
get2.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.args(["get", "my_api_key"]);
get2.assert()
.success()
.stdout(predicate::str::contains("new_val"));
// delete
let mut del = Command::cargo_bin("gman").unwrap();
del.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.args(["delete", "my_api_key"]);
del.assert().success();
// get should now fail
let mut get_missing = Command::cargo_bin("gman").unwrap();
get_missing
.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.args(["get", "my_api_key"]);
get_missing.assert().failure();
}
#[test]
fn cli_wrap_dry_run_env_injection() {
let (td, xdg_cfg, xdg_cache) = setup_env();
let pw_file = td.path().join("pw.txt");
fs::write(&pw_file, b"pw\n").unwrap();
write_yaml_config(&xdg_cfg, &pw_file, Some("echo"));
// Add the secret so the profile can read it
let mut add = Command::cargo_bin("gman").unwrap();
add.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.stdin(Stdio::piped())
.args(["add", "api_key"]);
let mut child = add.spawn().unwrap();
use std::io::Write as _;
child.stdin.as_mut().unwrap().write_all(b"value\n").unwrap();
let add_out = child.wait_with_output().unwrap();
assert!(add_out.status.success());
// Dry-run wrapping: prints preview command
let mut wrap = Command::cargo_bin("gman").unwrap();
wrap.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.arg("--dry-run")
.args(["echo", "hello"]);
wrap.assert().success().stdout(
predicate::str::contains("Command to be executed:").or(predicate::str::contains("echo")),
);
}
-22
View File
@@ -1,22 +0,0 @@
use gman::providers::SupportedProvider;
use gman::providers::local::LocalProvider;
use pretty_assertions::assert_eq;
#[test]
fn test_provider_kind_from() {
enum ProviderKind {
Local,
}
impl From<ProviderKind> for SupportedProvider {
fn from(k: ProviderKind) -> Self {
match k {
ProviderKind::Local => SupportedProvider::Local(LocalProvider),
}
}
}
let provider_kind = ProviderKind::Local;
let supported_provider: SupportedProvider = provider_kind.into();
assert_eq!(supported_provider, SupportedProvider::Local(LocalProvider));
}
-1
View File
@@ -1 +0,0 @@
mod main_tests;
+1 -1
View File
@@ -1 +1 @@
mod gman;
mod cli_tests;
+111 -11
View File
@@ -1,9 +1,9 @@
#[cfg(test)]
mod tests {
use gman::config::{Config, RunConfig};
use gman::config::{Config, ProviderConfig, RunConfig};
use gman::providers::SupportedProvider;
use gman::providers::local::LocalProvider;
use pretty_assertions::{assert_eq, assert_str_eq};
use pretty_assertions::assert_eq;
use validator::Validate;
@@ -17,6 +17,7 @@ mod tests {
arg_format: None,
files: None,
};
assert!(run_config.validate().is_ok());
}
@@ -30,6 +31,7 @@ mod tests {
arg_format: None,
files: None,
};
assert!(run_config.validate().is_err());
}
@@ -43,6 +45,7 @@ mod tests {
arg_format: None,
files: None,
};
assert!(run_config.validate().is_err());
}
@@ -56,6 +59,7 @@ mod tests {
arg_format: Some("{{key}}={{value}}".to_string()),
files: None,
};
assert!(run_config.validate().is_err());
}
@@ -69,6 +73,7 @@ mod tests {
arg_format: Some("{{key}}={{value}}".to_string()),
files: None,
};
assert!(run_config.validate().is_ok());
}
@@ -82,6 +87,7 @@ mod tests {
arg_format: None,
files: None,
};
assert!(run_config.validate().is_ok());
}
@@ -95,6 +101,7 @@ mod tests {
arg_format: None,
files: None,
};
assert!(run_config.validate().is_err());
}
@@ -108,6 +115,7 @@ mod tests {
arg_format: Some("key=value".to_string()),
files: None,
};
assert!(run_config.validate().is_err());
}
@@ -121,6 +129,7 @@ mod tests {
arg_format: None,
files: None,
};
assert!(run_config.validate().is_ok());
}
@@ -134,6 +143,7 @@ mod tests {
arg_format: None,
files: Some(Vec::new()),
};
assert!(run_config.validate().is_ok());
}
@@ -147,12 +157,14 @@ mod tests {
arg_format: Some("{{key}}={{value}}".to_string()),
files: Some(Vec::new()),
};
assert!(run_config.validate().is_err());
}
#[test]
fn test_config_valid() {
let config = Config {
fn test_provider_config_valid() {
let config = ProviderConfig {
name: Some("local-test".to_string()),
provider: SupportedProvider::Local(LocalProvider),
password_file: None,
git_branch: None,
@@ -160,14 +172,15 @@ mod tests {
git_user_name: None,
git_user_email: Some("test@example.com".to_string()),
git_executable: None,
run_configs: None,
};
assert!(config.validate().is_ok());
}
#[test]
fn test_config_invalid_email() {
let config = Config {
fn test_provider_config_invalid_email() {
let config = ProviderConfig {
name: Some("local-test".to_string()),
provider: SupportedProvider::Local(LocalProvider),
password_file: None,
git_branch: None,
@@ -175,23 +188,110 @@ mod tests {
git_user_name: None,
git_user_email: Some("test".to_string()),
git_executable: None,
};
assert!(config.validate().is_err());
}
#[test]
fn test_provider_config_missing_name() {
let config = ProviderConfig {
name: None,
provider: SupportedProvider::Local(LocalProvider),
password_file: None,
git_branch: None,
git_remote_url: None,
git_user_name: None,
git_user_email: None,
git_executable: None,
};
assert!(config.validate().is_err());
}
#[test]
fn test_provider_config_default() {
let config = ProviderConfig::default();
assert_eq!(config.name, Some("local".to_string()));
assert_eq!(config.git_user_email, None);
assert_eq!(config.password_file, Config::local_provider_password_file());
assert_eq!(config.git_branch, Some("main".into()));
assert_eq!(config.git_remote_url, None);
assert_eq!(config.git_user_name, None);
assert_eq!(config.git_executable, None);
}
#[test]
fn test_config_valid() {
let config = Config {
default_provider: Some("local".into()),
providers: vec![ProviderConfig::default()],
run_configs: None,
};
assert!(config.validate().is_ok());
}
#[test]
fn test_config_invalid_default_provider() {
let config = Config {
default_provider: Some("nonexistent".into()),
providers: vec![ProviderConfig::default()],
run_configs: None,
};
assert!(config.validate().is_err());
}
#[test]
fn test_config_invalid_no_providers() {
let config = Config {
default_provider: Some("local".into()),
providers: vec![],
run_configs: None,
};
assert!(config.validate().is_err());
}
#[test]
fn test_config_default() {
let config = Config::default();
assert_eq!(config.provider, SupportedProvider::Local(LocalProvider));
assert_eq!(config.git_branch, Some("main".to_string()));
assert_eq!(config.default_provider, Some("local".to_string()));
assert_eq!(config.providers, vec![ProviderConfig::default()]);
assert_eq!(config.run_configs, None);
}
#[test]
fn test_config_extract_provider() {
let config = Config::default();
let provider = config.extract_provider();
assert_str_eq!(provider.name(), "LocalProvider");
let provider = config.extract_provider_config(None).unwrap();
assert_eq!(provider.name, Some("local".to_string()));
}
#[test]
fn test_config_extract_provider_with_name() {
let mut config = Config::default();
config.providers.push(ProviderConfig {
name: Some("custom".to_string()),
..Default::default()
});
let provider = config
.extract_provider_config(Some("custom".into()))
.unwrap();
assert_eq!(provider.name, Some("custom".to_string()));
}
#[test]
fn test_config_extract_provider_not_found() {
let config = Config::default();
let result = config.extract_provider_config(Some("nonexistent".into()));
assert!(result.is_err());
}
#[test]
+32
View File
@@ -0,0 +1,32 @@
use base64::Engine;
use gman::{decrypt_string, encrypt_string};
use proptest::prelude::*;
use secrecy::SecretString;
proptest! {
#[test]
fn prop_encrypt_decrypt_roundtrip(password in ".{0,64}", msg in ".{0,2048}") {
let pw = SecretString::new(password.into());
let env = encrypt_string(pw.clone(), &msg).unwrap();
let out = decrypt_string(pw, &env).unwrap();
prop_assert_eq!(out, msg);
}
#[test]
fn prop_tamper_ciphertext_detected(password in ".{0,32}", msg in ".{1,256}") {
let pw = SecretString::new(password.into());
let env = encrypt_string(pw.clone(), &msg).unwrap();
// Flip a bit in the ct payload segment
let mut parts: Vec<&str> = env.split(';').collect();
let ct_b64 = parts[6].strip_prefix("ct=").unwrap();
let mut ct = base64::engine::general_purpose::STANDARD.decode(ct_b64).unwrap();
ct[0] ^= 0x1;
let new_ct_b64 = base64::engine::general_purpose::STANDARD.encode(&ct);
let new_ct = format!("ct={}", new_ct_b64);
parts[6] = Box::leak(new_ct.into_boxed_str());
let tampered = parts.join(";");
prop_assert!(decrypt_string(pw, &tampered).is_err());
}
}
-53
View File
@@ -1,53 +0,0 @@
use anyhow::Result;
use validator::Validate;
// Redefining the struct here for testing purposes
pub struct SyncOpts<'a> {
pub remote_url: &'a Option<String>,
pub branch: &'a Option<String>,
}
impl<'a> Validate for SyncOpts<'a> {
fn validate(&self) -> Result<(), validator::ValidationErrors> {
if self.remote_url.is_none() {
return Err(validator::ValidationErrors::new());
}
if self.branch.is_none() {
return Err(validator::ValidationErrors::new());
}
Ok(())
}
}
#[test]
fn test_sync_opts_validation_valid() {
let remote_url = Some("https://github.com/user/repo.git".to_string());
let branch = Some("main".to_string());
let opts = SyncOpts {
remote_url: &remote_url,
branch: &branch,
};
assert!(opts.validate().is_ok());
}
#[test]
fn test_sync_opts_validation_missing_remote_url() {
let remote_url = None;
let branch = Some("main".to_string());
let opts = SyncOpts {
remote_url: &remote_url,
branch: &branch,
};
assert!(opts.validate().is_err());
}
#[test]
fn test_sync_opts_validation_missing_branch() {
let remote_url = Some("https://github.com/user/repo.git".to_string());
let branch = None;
let opts = SyncOpts {
remote_url: &remote_url,
branch: &branch,
};
assert!(opts.validate().is_err());
}
-1
View File
@@ -1,3 +1,2 @@
mod git_sync_tests;
mod local_tests;
mod provider_tests;
+2 -1
View File
@@ -1,3 +1,4 @@
mod bin;
mod config_tests;
mod providers;
mod bin;
mod prop_crypto;