From a8d959dac35aee209e2de3f64720bdf5f6898361 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 11 Sep 2025 15:07:16 -0600 Subject: [PATCH] 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. --- Cargo.lock | 176 ++++++++++++++++++++++++++ Cargo.toml | 3 + README.md | 130 ++++++++++++++------ src/bin/gman/cli.rs | 126 +++++++++++++++++-- src/bin/gman/main.rs | 64 +++------- src/config.rs | 176 +++++++++++++++++++++++--- src/lib.rs | 67 ++++++++-- src/providers/git_sync.rs | 109 ++++++++++++++-- src/providers/local.rs | 74 ++++++++--- src/providers/mod.rs | 35 +++++- tests/bin/cli_tests.rs | 198 ++++++++++++++++++++++++++++++ tests/bin/gman/main_tests.rs | 22 ---- tests/bin/gman/mod.rs | 1 - tests/bin/mod.rs | 2 +- tests/config_tests.rs | 122 ++++++++++++++++-- tests/prop_crypto.rs | 32 +++++ tests/providers/git_sync_tests.rs | 53 -------- tests/providers/mod.rs | 1 - tests/tests.rs | 3 +- 19 files changed, 1155 insertions(+), 239 deletions(-) create mode 100644 tests/bin/cli_tests.rs delete mode 100644 tests/bin/gman/main_tests.rs delete mode 100644 tests/bin/gman/mod.rs create mode 100644 tests/prop_crypto.rs delete mode 100644 tests/providers/git_sync_tests.rs diff --git a/Cargo.lock b/Cargo.lock index a6eb923..1defce1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 8c12c73..7539245 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md index 3c70d1c..4c6a51e 100644 --- a/README.md +++ b/README.md @@ -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 --- -provider: local -password_file: ~/.gman_password +default_provider: local +providers: + - name: local + provider: local + password_file: ~/.gman_password + # Optional Git sync settings for the 'local' provider + 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 -# Optional Git sync settings for the 'local' provider -git_branch: null # Defaults to 'main' -git_remote_url: null # Required for Git sync -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,14 +144,21 @@ 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 -provider: local -git_branch: main -git_remote_url: "git@github.com:my-user/gman-secrets.git" -git_user_name: "Your Name" -git_user_email: "your.email@example.com" +default_provider: local +providers: + - name: local + provider: local + git_branch: main + git_remote_url: "git@github.com:my-user/gman-secrets.git" + git_user_name: "Your Name" + git_user_email: "your.email@example.com" ``` ## Run Configurations @@ -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 ``. 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) \ No newline at end of file +* [Alex Clarke](https://github.com/Dark-Alex-17) diff --git a/src/bin/gman/cli.rs b/src/bin/gman/cli.rs index 6401a0f..34c55cc 100644 --- a/src/bin/gman/cli.rs +++ b/src/bin/gman/cli.rs @@ -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, config: &Config, + provider_config: &ProviderConfig, tokens: Vec, profile_name: Option, 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, 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, dry_run: bool, ) -> Result> { - 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 { + 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::>() + ); + + // 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::>() + ); + } + + #[test] + fn test_wrap_and_run_command_no_profile() { + let cfg = Config::default(); + let provider_cfg = ProviderConfig::default(); + let prov: Box = 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 = 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 + } } diff --git a/src/bin/gman/main.rs b/src/bin/gman/main.rs index 2fc8e8e..3f4aec3 100644 --- a/src/bin/gman/main.rs +++ b/src/bin/gman/main.rs @@ -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 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, - /// 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, + provider: Option, /// 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 { - 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 { if io::stdin().is_terminal() { #[cfg(not(windows))] diff --git a/src/config.rs b/src/config.rs index 23e2282..7165ea3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 file‑injection 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, #[serde_as(as = "DisplayFromStr")] pub provider: SupportedProvider, pub password_file: Option, @@ -79,26 +124,31 @@ pub struct Config { #[validate(email)] pub git_user_email: Option, pub git_executable: Option, - #[validate(nested)] - pub run_configs: Option>, } -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 { match &self.provider { SupportedProvider::Local(p) => { @@ -107,15 +157,109 @@ impl Config { } } } +} - pub fn local_provider_password_file() -> Option { - let mut path = dirs::home_dir().map(|p| p.join(".gman_password")); - if let Some(p) = &path - && !p.exists() - { - path = None; +#[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, + #[validate(length(min = 1))] + #[validate(nested)] + pub providers: Vec, + #[validate(nested)] + pub run_configs: Option>, +} + +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, } - - path } } + +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) -> Result { + 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 { + let candidate = dirs::home_dir().map(|p| p.join(".gman_password")); + match candidate { + Some(p) if p.exists() => Some(p), + _ => None, + } + } +} + +pub fn load_config() -> Result { + 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) +} diff --git a/src/lib.rs b/src/lib.rs index fe292f7..eb968f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 { .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 UTF‑8 string using a password and return a portable envelope. +/// +/// The returned value is a semicolon‑separated 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, plaintext: &str) -> Result { let password = password.into(); @@ -87,6 +125,21 @@ pub fn encrypt_string(password: impl Into, 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, envelope: &str) -> Result { let password = password.into(); diff --git a/src/providers/git_sync.rs b/src/providers/git_sync.rs index d306882..bb46803 100644 --- a/src/providers/git_sync.rs +++ b/src/providers/git_sync.rs @@ -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")?; - run_git( - git, - repo, - &["merge", "--ff-only", &format!("origin/{branch}")], - ) - .with_context(|| "Failed to merge remote changes")?; + + 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", &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"); + } + } +} diff --git a/src/providers/local.rs b/src/providers/local.rs index c42d4ad..cd34ad0 100644 --- a/src/providers/local.rs +++ b/src/providers/local.rs @@ -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 { + fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result { let vault: HashMap = 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 = 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 = 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 { Ok(s) } -fn get_password(config: &Config) -> Result { +fn get_password(config: &ProviderConfig) -> Result { 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 { #[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"); + } } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 0697fe0..5eb4fb8 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -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; - 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; + 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; diff --git a/tests/bin/cli_tests.rs b/tests/bin/cli_tests.rs new file mode 100644 index 0000000..5a5ad56 --- /dev/null +++ b/tests/bin/cli_tests.rs @@ -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")), + ); +} diff --git a/tests/bin/gman/main_tests.rs b/tests/bin/gman/main_tests.rs deleted file mode 100644 index b85a3c8..0000000 --- a/tests/bin/gman/main_tests.rs +++ /dev/null @@ -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 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)); -} diff --git a/tests/bin/gman/mod.rs b/tests/bin/gman/mod.rs deleted file mode 100644 index 987fc67..0000000 --- a/tests/bin/gman/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod main_tests; diff --git a/tests/bin/mod.rs b/tests/bin/mod.rs index ad12506..c950dfc 100644 --- a/tests/bin/mod.rs +++ b/tests/bin/mod.rs @@ -1 +1 @@ -mod gman; +mod cli_tests; \ No newline at end of file diff --git a/tests/config_tests.rs b/tests/config_tests.rs index 4213219..2290427 100644 --- a/tests/config_tests.rs +++ b/tests/config_tests.rs @@ -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] diff --git a/tests/prop_crypto.rs b/tests/prop_crypto.rs new file mode 100644 index 0000000..9b21aa7 --- /dev/null +++ b/tests/prop_crypto.rs @@ -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()); + } +} diff --git a/tests/providers/git_sync_tests.rs b/tests/providers/git_sync_tests.rs deleted file mode 100644 index 5536ca8..0000000 --- a/tests/providers/git_sync_tests.rs +++ /dev/null @@ -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, - pub branch: &'a Option, -} - -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()); -} diff --git a/tests/providers/mod.rs b/tests/providers/mod.rs index 52f1f61..f334fe8 100644 --- a/tests/providers/mod.rs +++ b/tests/providers/mod.rs @@ -1,3 +1,2 @@ -mod git_sync_tests; mod local_tests; mod provider_tests; diff --git a/tests/tests.rs b/tests/tests.rs index ca99655..b485920 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1,3 +1,4 @@ -mod bin; mod config_tests; mod providers; +mod bin; +mod prop_crypto; \ No newline at end of file