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", "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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.0"
@@ -152,6 +168,21 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.9.4" version = "2.9.4"
@@ -176,6 +207,17 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.0" version = "3.19.0"
@@ -481,6 +523,12 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "difflib"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -533,6 +581,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "doc-comment"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
[[package]] [[package]]
name = "document-features" name = "document-features"
version = "0.2.11" version = "0.2.11"
@@ -582,6 +636,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" 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]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -642,6 +705,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
"assert_cmd",
"backtrace", "backtrace",
"base64", "base64",
"chacha20poly1305", "chacha20poly1305",
@@ -657,7 +721,9 @@ dependencies = [
"indoc", "indoc",
"log", "log",
"log4rs", "log4rs",
"predicates",
"pretty_assertions", "pretty_assertions",
"proptest",
"regex", "regex",
"rpassword", "rpassword",
"secrecy", "secrecy",
@@ -912,6 +978,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.175" version = "0.2.175"
@@ -1033,6 +1105,12 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6"
[[package]]
name = "normalize-line-endings"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -1190,6 +1268,36 @@ dependencies = [
"zerocopy", "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]] [[package]]
name = "pretty_assertions" name = "pretty_assertions"
version = "1.4.1" version = "1.4.1"
@@ -1231,6 +1339,32 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.3" version = "0.38.3"
@@ -1293,6 +1427,15 @@ dependencies = [
"getrandom 0.3.3", "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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.17" version = "0.5.17"
@@ -1408,6 +1551,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 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]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.20" version = "1.0.20"
@@ -1660,6 +1815,12 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.16" version = "2.0.16"
@@ -1772,6 +1933,12 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.18" version = "1.0.18"
@@ -1890,6 +2057,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 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]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
+3
View File
@@ -44,6 +44,9 @@ regex = "1.11.2"
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
tempfile = "3.10.1" tempfile = "3.10.1"
proptest = "1.5.0"
assert_cmd = "2.0.16"
predicates = "3.1.2"
[[bin]] [[bin]]
bench = false bench = false
+90 -38
View File
@@ -1,30 +1,25 @@
# gman - Universal Credential Manager # gman - Universal Credential Manager
`gman` is a command-line tool designed to streamline credential and secret management for your scripts, automations, and `gman` is a command-line tool for managing and injecting secrets for your scripts, automations, and applications.
applications. It provides a single, secure interface to store, retrieve, and inject secrets, eliminating the need to It provides a single, secure interface to store, retrieve, and inject secrets so you can stop hand-rolling config
juggle different methods like configuration files or environment variables for each tool. files or sprinkling environment variables everywhere.
## Overview ## Overview
The core philosophy of `gman` is to act as a universal wrapper for any command that requires credentials. You can store `gman` acts as a universal wrapper for any command that needs credentials. Store your secrets—API tokens, passwords,
your secrets—like API tokens, passwords, or certificates—in an encrypted vault backed by various providers. Then, you certs—with a provider, then either fetch them directly or run your command through `gman` to inject what it needs as
can either fetch them directly or, more powerfully, execute commands through `gman`, which securely injects the environment variables, flags, or file content.
necessary secrets as environment variables or command-line flags.
## Features ## Features
- **Secure, Encrypted Storage**: All secrets are stored in an encrypted state using strong cryptography. - Secure encryption for stored secrets
- **Pluggable Providers**: Supports different backends for secret storage. The default is a local file-based system. - Pluggable providers (local by default; more can be added)
- **Git Synchronization**: The `local` provider can synchronize your encrypted secrets across multiple systems using a - Git sync for local vaults to move secrets across machines
private Git repository. - Command wrapping to inject secrets for any program
- **Seamless Command Wrapping**: Run any command through `gman` to automatically provide it with the secrets it needs - Customizable run profiles (env, flags, or files)
(e.g., `gman aws s3 ls`). - Consistent secret naming: input is snake_case; injected as UPPER_SNAKE_CASE
- **Customizable Run Profiles**: Define how secrets are passed to commands, either as environment variables (default) or - Direct retrieval via `gman get ...`
as specific command-line flags. - Dry-run to preview wrapped commands and secret injection
- **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.
## Example Use Cases ## Example Use Cases
@@ -68,7 +63,7 @@ cargo install --locked gman
## Configuration ## 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 ### Linux
``` ```
@@ -87,23 +82,30 @@ $HOME/Library/Application Support/gman/config.yml
### Default Configuration ### Default Configuration
gman supports multiple providers. Select one as the default and then list provider configurations.
```yaml ```yaml
--- ---
provider: local default_provider: local
password_file: ~/.gman_password 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 # List of run configurations (profiles). See below.
git_branch: null # Defaults to 'main' run_configs: []
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)
``` ```
## Providers ## Providers
`gman` supports multiple providers for secret storage. The default provider is `local`, which stores secrets in an `gman` supports multiple providers for secret storage. The default provider is `local`, which stores secrets in an
encrypted file on your filesystem. The 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:** **Key:**
@@ -142,14 +144,21 @@ For use across multiple systems, `gman` can sync with a remote Git repository.
**Important Notes for Git Sync:** **Important Notes for Git Sync:**
- You **must** create the remote repository on your Git provider (e.g., GitHub) *before* attempting to 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`). - 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:** **Example `local` provider config for Git sync:**
```yaml ```yaml
provider: local default_provider: local
git_branch: main providers:
git_remote_url: "git@github.com:my-user/gman-secrets.git" - name: local
git_user_name: "Your Name" provider: local
git_user_email: "your.email@example.com" 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 ## 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 Run configurations (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are
supported: supported:
1. [**Environment Variables** (default)](#basic-run-config-environment-variables) 1. [**Environment Variables** (default)](#environment-variable-secret-injection)
2. [**Command-Line Flags**](#advanced-run-config-command-line-flags) 2. [**Command-Line Flags**](#inject-secrets-via-command-line-flags)
3. [**Files**](#advanced-run-config-files) 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 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` 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 # 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 ## Creator
* [Alex Clarke](https://github.com/Dark-Alex-17) * [Alex Clarke](https://github.com/Dark-Alex-17)
+114 -12
View File
@@ -1,6 +1,6 @@
use crate::command::preview_command; use crate::command::preview_command;
use anyhow::{Context, Result, anyhow}; use anyhow::{anyhow, Context, Result};
use gman::config::{Config, RunConfig}; use gman::config::{Config, ProviderConfig, RunConfig};
use gman::providers::SecretProvider; use gman::providers::SecretProvider;
use heck::ToSnakeCase; use heck::ToSnakeCase;
use log::{debug, error}; use log::{debug, error};
@@ -13,9 +13,11 @@ use std::process::Command;
const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}"; const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}";
const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}"; const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}";
pub fn wrap_and_run_command( pub fn wrap_and_run_command(
secrets_provider: Box<dyn SecretProvider>, secrets_provider: Box<dyn SecretProvider>,
config: &Config, config: &Config,
provider_config: &ProviderConfig,
tokens: Vec<OsString>, tokens: Vec<OsString>,
profile_name: Option<String>, profile_name: Option<String>,
dry_run: bool, dry_run: bool,
@@ -30,15 +32,17 @@ pub fn wrap_and_run_command(
.ok_or_else(|| anyhow!("failed to convert program name to string"))? .ok_or_else(|| anyhow!("failed to convert program name to string"))?
}; };
let run_config_opt = config.run_configs.as_ref().and_then(|configs| { let run_config_opt = config.run_configs.as_ref().and_then(|configs| {
configs.iter().filter(|c| c.name.is_some()).find(|c| { configs
c.name.as_ref().expect("failed to unwrap run config name") == run_config_profile_name .iter()
}) .find(|c| c.name.as_deref() == Some(run_config_profile_name))
}); });
if let Some(run_cfg) = run_config_opt { if let Some(run_cfg) = run_config_opt {
let secrets_result = run_cfg let secrets_result = run_cfg
.secrets .secrets
.as_ref() .as_ref()
.expect("no secrets configured for run profile") .ok_or_else(|| {
anyhow!("No secrets configured for run profile '{run_config_profile_name}'")
})?
.iter() .iter()
.map(|key| { .map(|key| {
let secret_name = key.to_snake_case().to_uppercase(); let secret_name = key.to_snake_case().to_uppercase();
@@ -47,7 +51,7 @@ pub fn wrap_and_run_command(
run_config_profile_name run_config_profile_name
); );
secrets_provider 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() .ok()
.map_or_else( .map_or_else(
|| { || {
@@ -157,6 +161,7 @@ pub fn wrap_and_run_command(
} }
Ok(()) Ok(())
} }
fn generate_files_secret_injections( fn generate_files_secret_injections(
secrets: HashMap<String, String>, secrets: HashMap<String, String>,
run_config: &RunConfig, run_config: &RunConfig,
@@ -189,22 +194,24 @@ fn generate_files_secret_injections(
} }
Ok(results) Ok(results)
} }
pub fn run_cmd(cmd: &mut Command, dry_run: bool) -> Result<()> { pub fn run_cmd(cmd: &mut Command, dry_run: bool) -> Result<()> {
if dry_run { if dry_run {
eprintln!("Command to be executed: {}", preview_command(cmd)); println!("Command to be executed: {}", preview_command(cmd));
} else { } else {
cmd.status() cmd.status()
.with_context(|| format!("failed to execute command '{:?}'", cmd))?; .with_context(|| format!("failed to execute command '{:?}'", cmd))?;
} }
Ok(()) Ok(())
} }
pub fn parse_args( pub fn parse_args(
args: &[OsString], args: &[OsString],
run_config: &RunConfig, run_config: &RunConfig,
secrets: HashMap<String, String>, secrets: HashMap<String, String>,
dry_run: bool, dry_run: bool,
) -> Result<Vec<OsString>> { ) -> Result<Vec<OsString>> {
let args = args.to_vec(); let mut args = args.to_vec();
let flag = run_config let flag = run_config
.flag .flag
.as_ref() .as_ref()
@@ -216,7 +223,6 @@ pub fn parse_args(
.arg_format .arg_format
.as_ref() .as_ref()
.ok_or_else(|| anyhow!("arg_format must be set if flag is set"))?; .ok_or_else(|| anyhow!("arg_format must be set if flag is set"))?;
let mut args = args.to_vec();
if flag_position > args.len() { if flag_position > args.len() {
secrets.iter().for_each(|(k, v)| { secrets.iter().for_each(|(k, v)| {
let v = if dry_run { "*****" } else { v }; let v = if dry_run { "*****" } else { v };
@@ -246,10 +252,31 @@ pub fn parse_args(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*;
use crate::cli::generate_files_secret_injections; 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 pretty_assertions::{assert_eq, assert_str_eq};
use std::collections::HashMap; 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] #[test]
fn test_generate_files_secret_injections() { fn test_generate_files_secret_injections() {
@@ -257,7 +284,7 @@ mod tests {
secrets.insert("SECRET1".to_string(), "value1".to_string()); secrets.insert("SECRET1".to_string(), "value1".to_string());
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt"); 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 { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
@@ -275,4 +302,79 @@ mod tests {
assert_str_eq!(result[0].1, "{{secret1}}"); assert_str_eq!(result[0].1, "{{secret1}}");
assert_str_eq!(result[0].2, "value1"); 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::{ 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 std::ffi::OsString;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Subcommand; use clap::Subcommand;
use crossterm::execute; use crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode}; use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen};
use gman::config::Config; use gman::config::load_config;
use gman::providers::SupportedProvider;
use gman::providers::local::LocalProvider;
use heck::ToSnakeCase; use heck::ToSnakeCase;
use std::io::{self, IsTerminal, Read, Write}; use std::io::{self, IsTerminal, Read, Write};
use std::panic::PanicHookInfo; use std::panic::PanicHookInfo;
use crate::cli::wrap_and_run_command; use crate::cli::wrap_and_run_command;
use std::panic; use std::panic;
use validator::Validate;
mod cli; mod cli;
mod command; mod command;
@@ -28,20 +25,6 @@ enum OutputFormat {
Json, 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)] #[derive(Debug, Parser)]
#[command( #[command(
name = crate_name!(), name = crate_name!(),
@@ -61,9 +44,9 @@ struct Cli {
#[arg(short, long, value_enum)] #[arg(short, long, value_enum)]
output: Option<OutputFormat>, 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)] #[arg(long, value_enum)]
provider: Option<ProviderKind>, provider: Option<String>,
/// Specify a run profile to use when wrapping a command /// Specify a run profile to use when wrapping a command
#[arg(long, short)] #[arg(long, short)]
@@ -130,8 +113,9 @@ fn main() -> Result<()> {
panic_hook(info); panic_hook(info);
})); }));
let cli = Cli::parse(); let cli = Cli::parse();
let mut config = load_config(&cli)?; let config = load_config()?;
let secrets_provider = config.extract_provider(); let mut provider_config = config.extract_provider_config(cli.provider.clone())?;
let secrets_provider = provider_config.extract_provider();
match cli.command { match cli.command {
Commands::Add { name } => { Commands::Add { name } => {
@@ -139,7 +123,7 @@ fn main() -> Result<()> {
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?; read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
let snake_case_name = name.to_snake_case().to_uppercase(); let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider 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 { .map(|_| match cli.output {
Some(_) => (), Some(_) => (),
None => println!("✓ Secret '{snake_case_name}' added to the vault."), None => println!("✓ Secret '{snake_case_name}' added to the vault."),
@@ -148,7 +132,7 @@ fn main() -> Result<()> {
Commands::Get { name } => { Commands::Get { name } => {
let snake_case_name = name.to_snake_case().to_uppercase(); let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider secrets_provider
.get_secret(&config, &snake_case_name) .get_secret(&provider_config, &snake_case_name)
.map(|secret| match cli.output { .map(|secret| match cli.output {
Some(OutputFormat::Json) => { Some(OutputFormat::Json) => {
let json_output = serde_json::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")?; read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
let snake_case_name = name.to_snake_case().to_uppercase(); let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider 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 { .map(|_| match cli.output {
Some(_) => (), Some(_) => (),
None => println!("✓ Secret '{snake_case_name}' updated in the vault."), None => println!("✓ Secret '{snake_case_name}' updated in the vault."),
@@ -211,14 +195,21 @@ fn main() -> Result<()> {
} }
} }
Commands::Sync {} => { Commands::Sync {} => {
secrets_provider.sync(&mut config).map(|_| { secrets_provider.sync(&mut provider_config).map(|_| {
if cli.output.is_none() { if cli.output.is_none() {
println!("✓ Secrets synchronized with remote") println!("✓ Secrets synchronized with remote")
} }
})?; })?;
} }
Commands::External(tokens) => { 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 } => { Commands::Completions { shell } => {
let mut cmd = Cli::command(); let mut cmd = Cli::command();
@@ -230,21 +221,6 @@ fn main() -> Result<()> {
Ok(()) 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> { fn read_all_stdin() -> Result<String> {
if io::stdin().is_terminal() { if io::stdin().is_terminal() {
#[cfg(not(windows))] #[cfg(not(windows))]
+160 -16
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 crate::providers::{SecretProvider, SupportedProvider};
use anyhow::Result;
use log::debug; use log::debug;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as; use serde_with::serde_as;
use serde_with::{skip_serializing_none, DisplayFromStr};
use std::borrow::Cow; use std::borrow::Cow;
use std::path::PathBuf; use std::path::PathBuf;
use validator::{Validate, ValidationError}; 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)] #[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
#[validate(schema(function = "flags_or_none", skip_on_field_errors = false))] #[validate(schema(function = "flags_or_none", skip_on_field_errors = false))]
#[validate(schema(function = "flags_or_files"))] #[validate(schema(function = "flags_or_files"))]
@@ -68,8 +97,24 @@ fn flags_or_files(run_config: &RunConfig) -> Result<(), ValidationError> {
} }
#[serde_as] #[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)] #[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
pub struct Config { pub struct ProviderConfig {
#[validate(required)]
pub name: Option<String>,
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
pub provider: SupportedProvider, pub provider: SupportedProvider,
pub password_file: Option<PathBuf>, pub password_file: Option<PathBuf>,
@@ -79,26 +124,31 @@ pub struct Config {
#[validate(email)] #[validate(email)]
pub git_user_email: Option<String>, pub git_user_email: Option<String>,
pub git_executable: Option<PathBuf>, pub git_executable: Option<PathBuf>,
#[validate(nested)]
pub run_configs: Option<Vec<RunConfig>>,
} }
impl Default for Config { impl Default for ProviderConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
provider: SupportedProvider::Local(Default::default()), name: Some("local".into()),
provider: SupportedProvider::Local(LocalProvider),
password_file: Config::local_provider_password_file(), password_file: Config::local_provider_password_file(),
git_branch: Some("main".into()), git_branch: Some("main".into()),
git_remote_url: None, git_remote_url: None,
git_user_name: None, git_user_name: None,
git_user_email: None, git_user_email: None,
git_executable: 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> { pub fn extract_provider(&self) -> Box<dyn SecretProvider> {
match &self.provider { match &self.provider {
SupportedProvider::Local(p) => { SupportedProvider::Local(p) => {
@@ -107,15 +157,109 @@ impl Config {
} }
} }
} }
}
pub fn local_provider_password_file() -> Option<PathBuf> { #[serde_as]
let mut path = dirs::home_dir().map(|p| p.join(".gman_password")); #[skip_serializing_none]
if let Some(p) = &path /// Global configuration for the library and CLI.
&& !p.exists() ///
{ /// Example: pick a provider and validate the configuration
path = None; /// ```
/// 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 {
path 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 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<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::{ use argon2::{
password_hash::{rand_core::RngCore, SaltString},
Algorithm, Argon2, Params, Version, 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::{ use chacha20poly1305::{
Key, XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, OsRng}, aead::{Aead, KeyInit, OsRng},
Key, XChaCha20Poly1305, XNonce,
}; };
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use zeroize::Zeroize; use zeroize::Zeroize;
/// Configuration structures and helpers used by the CLI and library.
pub mod config; pub mod config;
/// Secret provider trait and implementations.
pub mod providers; pub mod providers;
pub(crate) const HEADER: &str = "$VAULT"; 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) .hash_password_into(password.expose_secret().as_bytes(), salt, &mut key_bytes)
.map_err(|e| anyhow!("argon2 into error: {:?}", e))?; .map_err(|e| anyhow!("argon2 into error: {:?}", e))?;
let cloned_key_bytes = key_bytes; let key = *Key::from_slice(&key_bytes);
let key = Key::from_slice(&cloned_key_bytes);
key_bytes.zeroize(); 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> { pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Result<String> {
let password = password.into(); let password = password.into();
@@ -87,6 +125,21 @@ pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Res
Ok(env) 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> { pub fn decrypt_string(password: impl Into<SecretString>, envelope: &str) -> Result<String> {
let password = password.into(); 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)?; checkout_branch(&git, &repo_dir, branch)?;
set_origin(&git, &repo_dir, remote_url)?; 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)?; 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)?; commit_now(&git, &repo_dir, &commit_message)?;
run_git( 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<()> { 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")?; .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( run_git(
git, git,
repo, repo,
&["merge", "--ff-only", &format!("origin/{branch}")], &["merge", "--ff-only", &origin_ref],
) )
.with_context(|| "Failed to merge remote changes")?; .with_context(|| "Failed to merge remote changes")?;
}
Ok(()) 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 { fn has_head(git: &Path, repo: &Path) -> bool {
Command::new(git) Command::new(git)
.arg("-C") .arg("-C")
@@ -280,3 +320,54 @@ fn commit_now(git: &Path, repo: &Path, msg: &str) -> Result<()> {
Ok(()) 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 secrecy::{ExposeSecret, SecretString};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use zeroize::Zeroize; 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::SecretProvider;
use crate::providers::git_sync::{SyncOpts, sync_and_push};
use crate::{ use crate::{
ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION, ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION,
}; };
use anyhow::Result; use anyhow::Result;
use argon2::{Algorithm, Argon2, Params, Version}; 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::aead::rand_core::RngCore;
use chacha20poly1305::{ use chacha20poly1305::{
Key, XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, OsRng}, aead::{Aead, KeyInit, OsRng},
Key, XChaCha20Poly1305, XNonce,
}; };
use dialoguer::{Input, theme}; use dialoguer::{theme, Input};
use log::{debug, error}; use log::{debug, error};
use serde::Deserialize; use serde::Deserialize;
use theme::ColorfulTheme; use theme::ColorfulTheme;
use validator::Validate; use validator::Validate;
/// Configuration for the local file-based provider.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LocalProviderConfig { pub struct LocalProviderConfig {
pub vault_path: String, 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)] #[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
pub struct LocalProvider; pub struct LocalProvider;
@@ -48,7 +68,7 @@ impl SecretProvider for LocalProvider {
"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 vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
let envelope = vault let envelope = vault
.get(key) .get(key)
@@ -61,7 +81,7 @@ impl SecretProvider for LocalProvider {
Ok(plaintext) 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(); let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
if vault.contains_key(key) { if vault.contains_key(key) {
error!( error!(
@@ -79,7 +99,7 @@ impl SecretProvider for LocalProvider {
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault") 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 mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
let password = get_password(config)?; let password = get_password(config)?;
@@ -119,7 +139,7 @@ impl SecretProvider for LocalProvider {
Ok(keys) Ok(keys)
} }
fn sync(&self, config: &mut Config) -> Result<()> { fn sync(&self, config: &mut ProviderConfig) -> Result<()> {
let mut config_changed = false; let mut config_changed = false;
if config.git_branch.is_none() { if config.git_branch.is_none() {
@@ -139,9 +159,9 @@ impl SecretProvider for LocalProvider {
let remote: String = Input::with_theme(&ColorfulTheme::default()) let remote: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter remote git URL to sync with") .with_prompt("Enter remote git URL to sync with")
.validate_with(|s: &String| { .validate_with(|s: &String| {
Config { ProviderConfig {
git_remote_url: Some(s.clone()), git_remote_url: Some(s.clone()),
..Config::default() ..ProviderConfig::default()
} }
.validate() .validate()
.map(|_| ()) .map(|_| ())
@@ -314,7 +334,7 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> {
Ok(s) Ok(s)
} }
fn get_password(config: &Config) -> Result<SecretString> { fn get_password(config: &ProviderConfig) -> Result<SecretString> {
if let Some(password_file) = &config.password_file { if let Some(password_file) = &config.password_file {
let password = SecretString::new( let password = SecretString::new(
fs::read_to_string(password_file) fs::read_to_string(password_file)
@@ -333,10 +353,10 @@ fn get_password(config: &Config) -> Result<SecretString> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::derive_key; use super::*;
use crate::providers::local::derive_key_with_params;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use secrecy::SecretString; use secrecy::{ExposeSecret, SecretString};
use tempfile::tempdir;
#[test] #[test]
fn test_derive_key() { fn test_derive_key() {
@@ -353,4 +373,26 @@ mod tests {
let key = derive_key_with_params(&password, &salt, 10, 1, 1).unwrap(); let key = derive_key_with_params(&password, &salt, 10, 1, 1).unwrap();
assert_eq!(key.as_slice().len(), 32); 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; mod git_sync;
pub mod local; pub mod local;
use crate::config::Config; use crate::config::ProviderConfig;
use crate::providers::local::LocalProvider; use crate::providers::local::LocalProvider;
use anyhow::{Result, anyhow}; use anyhow::{anyhow, Result};
use serde::Deserialize; use serde::Deserialize;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::str::FromStr; use std::str::FromStr;
use thiserror::Error; use thiserror::Error;
/// A secret storage backend capable of CRUD and sync, with optional
/// update and listing
pub trait SecretProvider { pub trait SecretProvider {
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
fn get_secret(&self, config: &Config, key: &str) -> Result<String>; fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result<String>;
fn set_secret(&self, config: &Config, key: &str, value: &str) -> Result<()>; fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()>;
fn update_secret(&self, _config: &Config, _key: &str, _value: &str) -> Result<()> { fn update_secret(&self, _config: &ProviderConfig, _key: &str, _value: &str) -> Result<()> {
Err(anyhow!( Err(anyhow!(
"update secret not supported for provider {}", "update secret not supported for provider {}",
self.name() self.name()
@@ -26,20 +41,28 @@ pub trait SecretProvider {
self.name() 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)] #[derive(Debug, Error)]
pub enum ParseProviderError { pub enum ParseProviderError {
#[error("unsupported provider '{0}'")] #[error("unsupported provider '{0}'")]
Unsupported(String), Unsupported(String),
} }
/// Registry of built-in providers.
#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] #[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)]
pub enum SupportedProvider { pub enum SupportedProvider {
Local(LocalProvider), Local(LocalProvider),
} }
impl Default for SupportedProvider {
fn default() -> Self {
SupportedProvider::Local(LocalProvider)
}
}
impl FromStr for SupportedProvider { impl FromStr for SupportedProvider {
type Err = ParseProviderError; 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)] #[cfg(test)]
mod tests { mod tests {
use gman::config::{Config, RunConfig}; use gman::config::{Config, ProviderConfig, RunConfig};
use gman::providers::SupportedProvider; use gman::providers::SupportedProvider;
use gman::providers::local::LocalProvider; use gman::providers::local::LocalProvider;
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::assert_eq;
use validator::Validate; use validator::Validate;
@@ -17,6 +17,7 @@ mod tests {
arg_format: None, arg_format: None,
files: None, files: None,
}; };
assert!(run_config.validate().is_ok()); assert!(run_config.validate().is_ok());
} }
@@ -30,6 +31,7 @@ mod tests {
arg_format: None, arg_format: None,
files: None, files: None,
}; };
assert!(run_config.validate().is_err()); assert!(run_config.validate().is_err());
} }
@@ -43,6 +45,7 @@ mod tests {
arg_format: None, arg_format: None,
files: None, files: None,
}; };
assert!(run_config.validate().is_err()); assert!(run_config.validate().is_err());
} }
@@ -56,6 +59,7 @@ mod tests {
arg_format: Some("{{key}}={{value}}".to_string()), arg_format: Some("{{key}}={{value}}".to_string()),
files: None, files: None,
}; };
assert!(run_config.validate().is_err()); assert!(run_config.validate().is_err());
} }
@@ -69,6 +73,7 @@ mod tests {
arg_format: Some("{{key}}={{value}}".to_string()), arg_format: Some("{{key}}={{value}}".to_string()),
files: None, files: None,
}; };
assert!(run_config.validate().is_ok()); assert!(run_config.validate().is_ok());
} }
@@ -82,6 +87,7 @@ mod tests {
arg_format: None, arg_format: None,
files: None, files: None,
}; };
assert!(run_config.validate().is_ok()); assert!(run_config.validate().is_ok());
} }
@@ -95,6 +101,7 @@ mod tests {
arg_format: None, arg_format: None,
files: None, files: None,
}; };
assert!(run_config.validate().is_err()); assert!(run_config.validate().is_err());
} }
@@ -108,6 +115,7 @@ mod tests {
arg_format: Some("key=value".to_string()), arg_format: Some("key=value".to_string()),
files: None, files: None,
}; };
assert!(run_config.validate().is_err()); assert!(run_config.validate().is_err());
} }
@@ -121,6 +129,7 @@ mod tests {
arg_format: None, arg_format: None,
files: None, files: None,
}; };
assert!(run_config.validate().is_ok()); assert!(run_config.validate().is_ok());
} }
@@ -134,6 +143,7 @@ mod tests {
arg_format: None, arg_format: None,
files: Some(Vec::new()), files: Some(Vec::new()),
}; };
assert!(run_config.validate().is_ok()); assert!(run_config.validate().is_ok());
} }
@@ -147,12 +157,14 @@ mod tests {
arg_format: Some("{{key}}={{value}}".to_string()), arg_format: Some("{{key}}={{value}}".to_string()),
files: Some(Vec::new()), files: Some(Vec::new()),
}; };
assert!(run_config.validate().is_err()); assert!(run_config.validate().is_err());
} }
#[test] #[test]
fn test_config_valid() { fn test_provider_config_valid() {
let config = Config { let config = ProviderConfig {
name: Some("local-test".to_string()),
provider: SupportedProvider::Local(LocalProvider), provider: SupportedProvider::Local(LocalProvider),
password_file: None, password_file: None,
git_branch: None, git_branch: None,
@@ -160,14 +172,15 @@ mod tests {
git_user_name: None, git_user_name: None,
git_user_email: Some("test@example.com".to_string()), git_user_email: Some("test@example.com".to_string()),
git_executable: None, git_executable: None,
run_configs: None,
}; };
assert!(config.validate().is_ok()); assert!(config.validate().is_ok());
} }
#[test] #[test]
fn test_config_invalid_email() { fn test_provider_config_invalid_email() {
let config = Config { let config = ProviderConfig {
name: Some("local-test".to_string()),
provider: SupportedProvider::Local(LocalProvider), provider: SupportedProvider::Local(LocalProvider),
password_file: None, password_file: None,
git_branch: None, git_branch: None,
@@ -175,23 +188,110 @@ mod tests {
git_user_name: None, git_user_name: None,
git_user_email: Some("test".to_string()), git_user_email: Some("test".to_string()),
git_executable: None, 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, 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()); assert!(config.validate().is_err());
} }
#[test] #[test]
fn test_config_default() { fn test_config_default() {
let config = 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] #[test]
fn test_config_extract_provider() { fn test_config_extract_provider() {
let config = Config::default(); let config = Config::default();
let provider = config.extract_provider(); let provider = config.extract_provider_config(None).unwrap();
assert_str_eq!(provider.name(), "LocalProvider");
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] #[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 local_tests;
mod provider_tests; mod provider_tests;
+2 -1
View File
@@ -1,3 +1,4 @@
mod bin;
mod config_tests; mod config_tests;
mod providers; mod providers;
mod bin;
mod prop_crypto;