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:
Generated
+176
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
# gman - Universal Credential Manager
|
||||
|
||||
`gman` is a command-line tool designed to streamline credential and secret management for your scripts, automations, and
|
||||
applications. It provides a single, secure interface to store, retrieve, and inject secrets, eliminating the need to
|
||||
juggle different methods like configuration files or environment variables for each tool.
|
||||
`gman` is a command-line tool for managing and injecting secrets for your scripts, automations, and applications.
|
||||
It provides a single, secure interface to store, retrieve, and inject secrets so you can stop hand-rolling config
|
||||
files or sprinkling environment variables everywhere.
|
||||
|
||||
## Overview
|
||||
|
||||
The core philosophy of `gman` is to act as a universal wrapper for any command that requires credentials. You can store
|
||||
your secrets—like API tokens, passwords, or certificates—in an encrypted vault backed by various providers. Then, you
|
||||
can either fetch them directly or, more powerfully, execute commands through `gman`, which securely injects the
|
||||
necessary secrets as environment variables or command-line flags.
|
||||
`gman` acts as a universal wrapper for any command that needs credentials. Store your secrets—API tokens, passwords,
|
||||
certs—with a provider, then either fetch them directly or run your command through `gman` to inject what it needs as
|
||||
environment variables, flags, or file content.
|
||||
|
||||
## Features
|
||||
|
||||
- **Secure, Encrypted Storage**: All secrets are stored in an encrypted state using strong cryptography.
|
||||
- **Pluggable Providers**: Supports different backends for secret storage. The default is a local file-based system.
|
||||
- **Git Synchronization**: The `local` provider can synchronize your encrypted secrets across multiple systems using a
|
||||
private Git repository.
|
||||
- **Seamless Command Wrapping**: Run any command through `gman` to automatically provide it with the secrets it needs
|
||||
(e.g., `gman aws s3 ls`).
|
||||
- **Customizable Run Profiles**: Define how secrets are passed to commands, either as environment variables (default) or
|
||||
as specific command-line flags.
|
||||
- **Secret Name Standardization**: Enforces `snake_case` for all secret names to ensure consistency.
|
||||
- **Direct Secret Access**: Retrieve plaintext secrets directly when needed (e.g., `gman get my_api_key`).
|
||||
- **Dry Run Mode**: Preview the command and the secrets that will be injected without actually executing it using the
|
||||
`--dry-run` flag.
|
||||
- Secure encryption for stored secrets
|
||||
- Pluggable providers (local by default; more can be added)
|
||||
- Git sync for local vaults to move secrets across machines
|
||||
- Command wrapping to inject secrets for any program
|
||||
- Customizable run profiles (env, flags, or files)
|
||||
- Consistent secret naming: input is snake_case; injected as UPPER_SNAKE_CASE
|
||||
- Direct retrieval via `gman get ...`
|
||||
- Dry-run to preview wrapped commands and secret injection
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
@@ -68,7 +63,7 @@ cargo install --locked gman
|
||||
|
||||
## Configuration
|
||||
|
||||
`gman` is configured via a YAML file located somewhere different for each OS:
|
||||
`gman` reads a YAML configuration file located at an OS-specific path:
|
||||
|
||||
### Linux
|
||||
```
|
||||
@@ -87,23 +82,30 @@ $HOME/Library/Application Support/gman/config.yml
|
||||
|
||||
### Default Configuration
|
||||
|
||||
gman supports multiple providers. Select one as the default and then list provider configurations.
|
||||
|
||||
```yaml
|
||||
---
|
||||
default_provider: local
|
||||
providers:
|
||||
- name: local
|
||||
provider: local
|
||||
password_file: ~/.gman_password
|
||||
|
||||
# Optional Git sync settings for the 'local' provider
|
||||
git_branch: null # Defaults to 'main'
|
||||
git_remote_url: null # Required for Git sync
|
||||
git_branch: main # Defaults to 'main'
|
||||
git_remote_url: null # Set to enable Git sync (SSH or HTTPS)
|
||||
git_user_name: null # Defaults to global git config user.name
|
||||
git_user_email: null # Defaults to global git config user.email
|
||||
git_executable: null # Defaults to 'git' in PATH
|
||||
run_configs: null # List of run configurations (profiles)
|
||||
|
||||
# List of run configurations (profiles). See below.
|
||||
run_configs: []
|
||||
```
|
||||
|
||||
## Providers
|
||||
`gman` supports multiple providers for secret storage. The default provider is `local`, which stores secrets in an
|
||||
encrypted file on your filesystem. The following table shows the available and planned providers:
|
||||
encrypted file on your filesystem. The CLI and config format are designed to be extensible so new providers can be
|
||||
documented and added without breaking existing setups. The following table shows the available and planned providers:
|
||||
|
||||
**Key:**
|
||||
|
||||
@@ -142,9 +144,16 @@ For use across multiple systems, `gman` can sync with a remote Git repository.
|
||||
**Important Notes for Git Sync:**
|
||||
- You **must** create the remote repository on your Git provider (e.g., GitHub) *before* attempting to sync.
|
||||
- The `git_remote_url` must be in SSH or HTTPS format (e.g., `git@github.com:your-user/your-repo.git`).
|
||||
- First sync behavior:
|
||||
- If the remote already has content, `gman sync` adopts the remote state and discards uncommitted local changes in the
|
||||
vault directory to avoid merge conflicts.
|
||||
- If the remote is empty, `gman sync` initializes the repository locally, creates the first commit, and pushes.
|
||||
|
||||
**Example `local` provider config for Git sync:**
|
||||
```yaml
|
||||
default_provider: local
|
||||
providers:
|
||||
- name: local
|
||||
provider: local
|
||||
git_branch: main
|
||||
git_remote_url: "git@github.com:my-user/gman-secrets.git"
|
||||
@@ -157,9 +166,9 @@ git_user_email: "your.email@example.com"
|
||||
Run configurations (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are
|
||||
supported:
|
||||
|
||||
1. [**Environment Variables** (default)](#basic-run-config-environment-variables)
|
||||
2. [**Command-Line Flags**](#advanced-run-config-command-line-flags)
|
||||
3. [**Files**](#advanced-run-config-files)
|
||||
1. [**Environment Variables** (default)](#environment-variable-secret-injection)
|
||||
2. [**Command-Line Flags**](#inject-secrets-via-command-line-flags)
|
||||
3. [**Files**](#inject-secrets-into-files)
|
||||
|
||||
When you wrap a command with `gman` and don't specify a specific run configuration via `--profile`, `gman` will look for
|
||||
a profile with a `name` matching `<command>`. If found, it injects the specified secrets. If no profile is found, `gman`
|
||||
@@ -322,5 +331,48 @@ All secret names are automatically converted to `snake_case`.
|
||||
# Output will show: aws -e AWS_ACCESS_KEY_ID=***** ... s3 ls
|
||||
```
|
||||
|
||||
### Multiple Providers and Switching
|
||||
|
||||
You can define multiple providers—even multiple of the same type—and switch between them per command.
|
||||
|
||||
Example: two AWS Secrets Manager providers named `lab` and `prod`.
|
||||
|
||||
```yaml
|
||||
default_provider: prod
|
||||
providers:
|
||||
- name: lab
|
||||
provider: aws_secrets_manager
|
||||
# Additional provider-specific settings (e.g., region, role_arn, profile)
|
||||
# region: us-east-1
|
||||
# role_arn: arn:aws:iam::111111111111:role/lab-access
|
||||
|
||||
- name: prod
|
||||
provider: aws_secrets_manager
|
||||
# region: us-east-1
|
||||
# role_arn: arn:aws:iam::222222222222:role/prod-access
|
||||
|
||||
run_configs:
|
||||
- name: aws
|
||||
secrets:
|
||||
- aws_access_key_id
|
||||
- aws_secret_access_key
|
||||
```
|
||||
|
||||
Switch providers on the fly using the provider name defined in `providers`:
|
||||
|
||||
```sh
|
||||
# Use the default (prod)
|
||||
gman aws s3 ls
|
||||
|
||||
# Explicitly use lab
|
||||
gman --provider lab aws s3 ls
|
||||
|
||||
# Fetch a secret from prod
|
||||
gman get my_api_key
|
||||
|
||||
# Fetch a secret from lab
|
||||
gman --provider lab get my_api_key
|
||||
```
|
||||
|
||||
## Creator
|
||||
* [Alex Clarke](https://github.com/Dark-Alex-17)
|
||||
+114
-12
@@ -1,6 +1,6 @@
|
||||
use crate::command::preview_command;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use gman::config::{Config, RunConfig};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use gman::config::{Config, ProviderConfig, RunConfig};
|
||||
use gman::providers::SecretProvider;
|
||||
use heck::ToSnakeCase;
|
||||
use log::{debug, error};
|
||||
@@ -13,9 +13,11 @@ use std::process::Command;
|
||||
|
||||
const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}";
|
||||
const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}";
|
||||
|
||||
pub fn wrap_and_run_command(
|
||||
secrets_provider: Box<dyn SecretProvider>,
|
||||
config: &Config,
|
||||
provider_config: &ProviderConfig,
|
||||
tokens: Vec<OsString>,
|
||||
profile_name: Option<String>,
|
||||
dry_run: bool,
|
||||
@@ -30,15 +32,17 @@ pub fn wrap_and_run_command(
|
||||
.ok_or_else(|| anyhow!("failed to convert program name to string"))?
|
||||
};
|
||||
let run_config_opt = config.run_configs.as_ref().and_then(|configs| {
|
||||
configs.iter().filter(|c| c.name.is_some()).find(|c| {
|
||||
c.name.as_ref().expect("failed to unwrap run config name") == run_config_profile_name
|
||||
})
|
||||
configs
|
||||
.iter()
|
||||
.find(|c| c.name.as_deref() == Some(run_config_profile_name))
|
||||
});
|
||||
if let Some(run_cfg) = run_config_opt {
|
||||
let secrets_result = run_cfg
|
||||
.secrets
|
||||
.as_ref()
|
||||
.expect("no secrets configured for run profile")
|
||||
.ok_or_else(|| {
|
||||
anyhow!("No secrets configured for run profile '{run_config_profile_name}'")
|
||||
})?
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let secret_name = key.to_snake_case().to_uppercase();
|
||||
@@ -47,7 +51,7 @@ pub fn wrap_and_run_command(
|
||||
run_config_profile_name
|
||||
);
|
||||
secrets_provider
|
||||
.get_secret(config, key.to_snake_case().to_uppercase().as_str())
|
||||
.get_secret(provider_config, key.to_snake_case().to_uppercase().as_str())
|
||||
.ok()
|
||||
.map_or_else(
|
||||
|| {
|
||||
@@ -157,6 +161,7 @@ pub fn wrap_and_run_command(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_files_secret_injections(
|
||||
secrets: HashMap<String, String>,
|
||||
run_config: &RunConfig,
|
||||
@@ -189,22 +194,24 @@ fn generate_files_secret_injections(
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub fn run_cmd(cmd: &mut Command, dry_run: bool) -> Result<()> {
|
||||
if dry_run {
|
||||
eprintln!("Command to be executed: {}", preview_command(cmd));
|
||||
println!("Command to be executed: {}", preview_command(cmd));
|
||||
} else {
|
||||
cmd.status()
|
||||
.with_context(|| format!("failed to execute command '{:?}'", cmd))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_args(
|
||||
args: &[OsString],
|
||||
run_config: &RunConfig,
|
||||
secrets: HashMap<String, String>,
|
||||
dry_run: bool,
|
||||
) -> Result<Vec<OsString>> {
|
||||
let args = args.to_vec();
|
||||
let mut args = args.to_vec();
|
||||
let flag = run_config
|
||||
.flag
|
||||
.as_ref()
|
||||
@@ -216,7 +223,6 @@ pub fn parse_args(
|
||||
.arg_format
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("arg_format must be set if flag is set"))?;
|
||||
let mut args = args.to_vec();
|
||||
if flag_position > args.len() {
|
||||
secrets.iter().for_each(|(k, v)| {
|
||||
let v = if dry_run { "*****" } else { v };
|
||||
@@ -246,10 +252,31 @@ pub fn parse_args(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::generate_files_secret_injections;
|
||||
use gman::config::RunConfig;
|
||||
use gman::config::{Config, ProviderConfig, RunConfig};
|
||||
use pretty_assertions::{assert_eq, assert_str_eq};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
|
||||
struct DummyProvider;
|
||||
impl SecretProvider for DummyProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"Dummy"
|
||||
}
|
||||
fn get_secret(&self, _config: &ProviderConfig, key: &str) -> Result<String> {
|
||||
Ok(format!("{}_VAL", key))
|
||||
}
|
||||
fn set_secret(&self, _config: &ProviderConfig, _key: &str, _value: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn delete_secret(&self, _key: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn sync(&self, _config: &mut ProviderConfig) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_files_secret_injections() {
|
||||
@@ -257,7 +284,7 @@ mod tests {
|
||||
secrets.insert("SECRET1".to_string(), "value1".to_string());
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "{{secret1}}").unwrap();
|
||||
fs::write(&file_path, "{{secret1}}").unwrap();
|
||||
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
@@ -275,4 +302,79 @@ mod tests {
|
||||
assert_str_eq!(result[0].1, "{{secret1}}");
|
||||
assert_str_eq!(result[0].2, "value1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_args_insert_and_append() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("docker".into()),
|
||||
secrets: Some(vec!["api_key".into()]),
|
||||
files: None,
|
||||
flag: Some("-e".into()),
|
||||
flag_position: Some(1),
|
||||
arg_format: Some("{{key}}={{value}}".into()),
|
||||
};
|
||||
let mut secrets = HashMap::new();
|
||||
secrets.insert("API_KEY".into(), "xyz".into());
|
||||
|
||||
// Insert at position
|
||||
let args = vec![OsString::from("run"), OsString::from("image")];
|
||||
let out = parse_args(&args, &run_config, secrets.clone(), true).unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec!["run", "-e", "API_KEY=*****", "image"]
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// Append when position beyond len
|
||||
let run_config2 = RunConfig {
|
||||
flag_position: Some(99),
|
||||
..run_config.clone()
|
||||
};
|
||||
let out2 = parse_args(&args, &run_config2, secrets, true).unwrap();
|
||||
assert_eq!(
|
||||
out2,
|
||||
vec!["run", "image", "-e", "API_KEY=*****"]
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_and_run_command_no_profile() {
|
||||
let cfg = Config::default();
|
||||
let provider_cfg = ProviderConfig::default();
|
||||
let prov: Box<dyn SecretProvider> = Box::new(DummyProvider);
|
||||
let tokens = vec![OsString::from("echo"), OsString::from("hi")];
|
||||
let err = wrap_and_run_command(prov, &cfg, &provider_cfg, tokens, None, true).unwrap_err();
|
||||
assert!(err.to_string().contains("No run profile found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_and_run_command_env_injection_dry_run() {
|
||||
// Create a config with a matching run profile for command "echo"
|
||||
let run_cfg = RunConfig {
|
||||
name: Some("echo".into()),
|
||||
secrets: Some(vec!["api_key".into()]),
|
||||
files: None,
|
||||
flag: None,
|
||||
flag_position: None,
|
||||
arg_format: None,
|
||||
};
|
||||
let cfg = Config {
|
||||
run_configs: Some(vec![run_cfg]),
|
||||
..Config::default()
|
||||
};
|
||||
let provider_cfg = ProviderConfig::default();
|
||||
let prov: Box<dyn SecretProvider> = Box::new(DummyProvider);
|
||||
|
||||
// Capture stderr for dry_run preview
|
||||
let tokens = vec![OsString::from("echo"), OsString::from("hello")];
|
||||
// Best-effort: ensure function does not error under dry_run
|
||||
let res = wrap_and_run_command(prov, &cfg, &provider_cfg, tokens, None, true);
|
||||
assert!(res.is_ok());
|
||||
// Not asserting output text to keep test platform-agnostic
|
||||
}
|
||||
}
|
||||
|
||||
+20
-44
@@ -1,22 +1,19 @@
|
||||
use clap::{
|
||||
CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version,
|
||||
crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser, ValueEnum,
|
||||
};
|
||||
use std::ffi::OsString;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Subcommand;
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode};
|
||||
use gman::config::Config;
|
||||
use gman::providers::SupportedProvider;
|
||||
use gman::providers::local::LocalProvider;
|
||||
use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen};
|
||||
use gman::config::load_config;
|
||||
use heck::ToSnakeCase;
|
||||
use std::io::{self, IsTerminal, Read, Write};
|
||||
use std::panic::PanicHookInfo;
|
||||
|
||||
use crate::cli::wrap_and_run_command;
|
||||
use std::panic;
|
||||
use validator::Validate;
|
||||
|
||||
mod cli;
|
||||
mod command;
|
||||
@@ -28,20 +25,6 @@ enum OutputFormat {
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum)]
|
||||
#[clap(rename_all = "lower")]
|
||||
pub enum ProviderKind {
|
||||
Local,
|
||||
}
|
||||
|
||||
impl From<ProviderKind> for SupportedProvider {
|
||||
fn from(k: ProviderKind) -> Self {
|
||||
match k {
|
||||
ProviderKind::Local => SupportedProvider::Local(LocalProvider),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = crate_name!(),
|
||||
@@ -61,9 +44,9 @@ struct Cli {
|
||||
#[arg(short, long, value_enum)]
|
||||
output: Option<OutputFormat>,
|
||||
|
||||
/// Specify the secret provider to use (defaults to 'provider' in config or 'local')
|
||||
/// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local'))
|
||||
#[arg(long, value_enum)]
|
||||
provider: Option<ProviderKind>,
|
||||
provider: Option<String>,
|
||||
|
||||
/// Specify a run profile to use when wrapping a command
|
||||
#[arg(long, short)]
|
||||
@@ -130,8 +113,9 @@ fn main() -> Result<()> {
|
||||
panic_hook(info);
|
||||
}));
|
||||
let cli = Cli::parse();
|
||||
let mut config = load_config(&cli)?;
|
||||
let secrets_provider = config.extract_provider();
|
||||
let config = load_config()?;
|
||||
let mut provider_config = config.extract_provider_config(cli.provider.clone())?;
|
||||
let secrets_provider = provider_config.extract_provider();
|
||||
|
||||
match cli.command {
|
||||
Commands::Add { name } => {
|
||||
@@ -139,7 +123,7 @@ fn main() -> Result<()> {
|
||||
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
|
||||
let snake_case_name = name.to_snake_case().to_uppercase();
|
||||
secrets_provider
|
||||
.set_secret(&config, &snake_case_name, plaintext.trim_end())
|
||||
.set_secret(&provider_config, &snake_case_name, plaintext.trim_end())
|
||||
.map(|_| match cli.output {
|
||||
Some(_) => (),
|
||||
None => println!("✓ Secret '{snake_case_name}' added to the vault."),
|
||||
@@ -148,7 +132,7 @@ fn main() -> Result<()> {
|
||||
Commands::Get { name } => {
|
||||
let snake_case_name = name.to_snake_case().to_uppercase();
|
||||
secrets_provider
|
||||
.get_secret(&config, &snake_case_name)
|
||||
.get_secret(&provider_config, &snake_case_name)
|
||||
.map(|secret| match cli.output {
|
||||
Some(OutputFormat::Json) => {
|
||||
let json_output = serde_json::json!({
|
||||
@@ -170,7 +154,7 @@ fn main() -> Result<()> {
|
||||
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
|
||||
let snake_case_name = name.to_snake_case().to_uppercase();
|
||||
secrets_provider
|
||||
.update_secret(&config, &snake_case_name, plaintext.trim_end())
|
||||
.update_secret(&provider_config, &snake_case_name, plaintext.trim_end())
|
||||
.map(|_| match cli.output {
|
||||
Some(_) => (),
|
||||
None => println!("✓ Secret '{snake_case_name}' updated in the vault."),
|
||||
@@ -211,14 +195,21 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
Commands::Sync {} => {
|
||||
secrets_provider.sync(&mut config).map(|_| {
|
||||
secrets_provider.sync(&mut provider_config).map(|_| {
|
||||
if cli.output.is_none() {
|
||||
println!("✓ Secrets synchronized with remote")
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Commands::External(tokens) => {
|
||||
wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run)?;
|
||||
wrap_and_run_command(
|
||||
secrets_provider,
|
||||
&config,
|
||||
&provider_config,
|
||||
tokens,
|
||||
cli.profile,
|
||||
cli.dry_run,
|
||||
)?;
|
||||
}
|
||||
Commands::Completions { shell } => {
|
||||
let mut cmd = Cli::command();
|
||||
@@ -230,21 +221,6 @@ fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_config(cli: &Cli) -> Result<Config> {
|
||||
let mut config: Config = confy::load("gman", "config")?;
|
||||
config.validate()?;
|
||||
if let Some(local_password_file) = Config::local_provider_password_file() {
|
||||
config.password_file = Some(local_password_file);
|
||||
}
|
||||
|
||||
if let Some(provider_kind) = &cli.provider {
|
||||
let provider: SupportedProvider = provider_kind.clone().into();
|
||||
config.provider = provider;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn read_all_stdin() -> Result<String> {
|
||||
if io::stdin().is_terminal() {
|
||||
#[cfg(not(windows))]
|
||||
|
||||
+158
-14
@@ -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<String>,
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub provider: SupportedProvider,
|
||||
pub password_file: Option<PathBuf>,
|
||||
@@ -79,26 +124,31 @@ pub struct Config {
|
||||
#[validate(email)]
|
||||
pub git_user_email: Option<String>,
|
||||
pub git_executable: Option<PathBuf>,
|
||||
#[validate(nested)]
|
||||
pub run_configs: Option<Vec<RunConfig>>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
impl Default for ProviderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: SupportedProvider::Local(Default::default()),
|
||||
name: Some("local".into()),
|
||||
provider: SupportedProvider::Local(LocalProvider),
|
||||
password_file: Config::local_provider_password_file(),
|
||||
git_branch: Some("main".into()),
|
||||
git_remote_url: None,
|
||||
git_user_name: None,
|
||||
git_user_email: None,
|
||||
git_executable: None,
|
||||
run_configs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
impl ProviderConfig {
|
||||
/// Instantiate the configured secret provider.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use gman::config::ProviderConfig;
|
||||
/// let provider = ProviderConfig::default().extract_provider();
|
||||
/// println!("using provider: {}", provider.name());
|
||||
/// ```
|
||||
pub fn extract_provider(&self) -> Box<dyn SecretProvider> {
|
||||
match &self.provider {
|
||||
SupportedProvider::Local(p) => {
|
||||
@@ -107,15 +157,109 @@ impl Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[skip_serializing_none]
|
||||
/// Global configuration for the library and CLI.
|
||||
///
|
||||
/// Example: pick a provider and validate the configuration
|
||||
/// ```
|
||||
/// use gman::config::Config;
|
||||
/// use gman::config::ProviderConfig;
|
||||
/// use gman::providers::SupportedProvider;
|
||||
/// use gman::providers::local::LocalProvider;
|
||||
/// use validator::Validate;
|
||||
///
|
||||
/// let provider = SupportedProvider::Local(LocalProvider);
|
||||
/// let provider_config = ProviderConfig { provider, ..Default::default() };
|
||||
/// let cfg = Config{ providers: vec![provider_config], ..Default::default() };
|
||||
/// cfg.validate().unwrap();
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[validate(schema(function = "default_provider_exists"))]
|
||||
pub struct Config {
|
||||
pub default_provider: Option<String>,
|
||||
#[validate(length(min = 1))]
|
||||
#[validate(nested)]
|
||||
pub providers: Vec<ProviderConfig>,
|
||||
#[validate(nested)]
|
||||
pub run_configs: Option<Vec<RunConfig>>,
|
||||
}
|
||||
|
||||
fn default_provider_exists(config: &Config) -> Result<(), ValidationError> {
|
||||
if let Some(default) = &config.default_provider {
|
||||
if config.providers.iter().any(|p| p.name.as_deref() == Some(default)) {
|
||||
Ok(())
|
||||
} else {
|
||||
let mut err = ValidationError::new("default_provider_missing");
|
||||
err.message = Some(Cow::Borrowed(
|
||||
"The default_provider does not match any configured provider names",
|
||||
));
|
||||
Err(err)
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_provider: Some("local".into()),
|
||||
providers: vec![ProviderConfig::default()],
|
||||
run_configs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Instantiate the configured secret provider.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use gman::config::Config;
|
||||
/// let provider_config = Config::default().extract_provider_config(None);
|
||||
/// println!("using provider config: {}", provider_config.unwrap().name);
|
||||
/// ```
|
||||
pub fn extract_provider_config(&self, provider_name: Option<String>) -> Result<ProviderConfig> {
|
||||
let name = provider_name
|
||||
.or_else(|| self.default_provider.clone())
|
||||
.unwrap_or_else(|| "local".into());
|
||||
self.providers
|
||||
.iter()
|
||||
.find(|p| p.name.as_deref() == Some(&name))
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("No provider configuration found for '{}'", name))
|
||||
}
|
||||
|
||||
/// Discover the default password file for the local provider.
|
||||
///
|
||||
/// On most systems this resolves to `~/.gman_password` when the file
|
||||
/// exists, otherwise `None`.
|
||||
pub fn local_provider_password_file() -> Option<PathBuf> {
|
||||
let mut path = dirs::home_dir().map(|p| p.join(".gman_password"));
|
||||
if let Some(p) = &path
|
||||
&& !p.exists()
|
||||
{
|
||||
path = None;
|
||||
let candidate = dirs::home_dir().map(|p| p.join(".gman_password"));
|
||||
match candidate {
|
||||
Some(p) if p.exists() => Some(p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path
|
||||
pub fn load_config() -> Result<Config> {
|
||||
let mut config: Config = confy::load("gman", "config")?;
|
||||
config.validate()?;
|
||||
|
||||
config
|
||||
.providers
|
||||
.iter_mut()
|
||||
.filter(|p| matches!(p.provider, SupportedProvider::Local(_)))
|
||||
.for_each(|p| {
|
||||
if p.password_file.is_none()
|
||||
&& let Some(local_password_file) = Config::local_provider_password_file()
|
||||
{
|
||||
p.password_file = Some(local_password_file);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
+60
-7
@@ -1,16 +1,40 @@
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
//! Gman core library
|
||||
//!
|
||||
//! This crate provides two layers:
|
||||
//! - A small crypto helper API for envelope encrypting/decrypting strings.
|
||||
//! - Public modules for configuration and secret providers used by the CLI.
|
||||
//!
|
||||
//! Quick start for the crypto helpers:
|
||||
//!
|
||||
//! ```
|
||||
//! use gman::{encrypt_string, decrypt_string};
|
||||
//! use secrecy::SecretString;
|
||||
//!
|
||||
//! let password = SecretString::new("correct horse battery staple".into());
|
||||
//! let ciphertext = encrypt_string(password.clone(), "swordfish").unwrap();
|
||||
//! let plaintext = decrypt_string(password, &ciphertext).unwrap();
|
||||
//!
|
||||
//! assert_eq!(plaintext, "swordfish");
|
||||
//! ```
|
||||
//!
|
||||
//! The `config` and `providers` modules power the CLI. They can be embedded
|
||||
//! in other programs, but many functions interact with the user or the
|
||||
//! filesystem. Prefer `no_run` doctests for those.
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use argon2::{
|
||||
password_hash::{rand_core::RngCore, SaltString},
|
||||
Algorithm, Argon2, Params, Version,
|
||||
password_hash::{SaltString, rand_core::RngCore},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use chacha20poly1305::{
|
||||
Key, XChaCha20Poly1305, XNonce,
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
Key, XChaCha20Poly1305, XNonce,
|
||||
};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use zeroize::Zeroize;
|
||||
/// Configuration structures and helpers used by the CLI and library.
|
||||
pub mod config;
|
||||
/// Secret provider trait and implementations.
|
||||
pub mod providers;
|
||||
|
||||
pub(crate) const HEADER: &str = "$VAULT";
|
||||
@@ -35,12 +59,26 @@ fn derive_key(password: &SecretString, salt: &[u8]) -> Result<Key> {
|
||||
.hash_password_into(password.expose_secret().as_bytes(), salt, &mut key_bytes)
|
||||
.map_err(|e| anyhow!("argon2 into error: {:?}", e))?;
|
||||
|
||||
let cloned_key_bytes = key_bytes;
|
||||
let key = Key::from_slice(&cloned_key_bytes);
|
||||
let key = *Key::from_slice(&key_bytes);
|
||||
key_bytes.zeroize();
|
||||
Ok(*key)
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypt a 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<SecretString>, plaintext: &str) -> Result<String> {
|
||||
let password = password.into();
|
||||
|
||||
@@ -87,6 +125,21 @@ pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Res
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
/// Decrypt an envelope produced by [`encrypt_string`].
|
||||
///
|
||||
/// Returns the original plaintext on success or an error if the password is
|
||||
/// wrong, the envelope was tampered with, or the input is malformed.
|
||||
///
|
||||
/// Example
|
||||
/// ```
|
||||
/// use gman::{encrypt_string, decrypt_string};
|
||||
/// use secrecy::SecretString;
|
||||
///
|
||||
/// let pw = SecretString::new("pw".into());
|
||||
/// let env = encrypt_string(pw.clone(), "top secret").unwrap();
|
||||
/// let pt = decrypt_string(pw, &env).unwrap();
|
||||
/// assert_eq!(pt, "top secret");
|
||||
/// ```
|
||||
pub fn decrypt_string(password: impl Into<SecretString>, envelope: &str) -> Result<String> {
|
||||
let password = password.into();
|
||||
|
||||
|
||||
@@ -66,10 +66,16 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
|
||||
checkout_branch(&git, &repo_dir, branch)?;
|
||||
set_origin(&git, &repo_dir, remote_url)?;
|
||||
|
||||
stage_all(&git, &repo_dir)?;
|
||||
|
||||
// Always align local with remote before staging/committing. For a fresh
|
||||
// repo where the remote already has content, we intentionally discard any
|
||||
// local working tree changes and take the remote state to avoid merge
|
||||
// conflicts on first sync.
|
||||
fetch_and_pull(&git, &repo_dir, branch)?;
|
||||
|
||||
// Stage and commit any subsequent local changes after aligning with remote
|
||||
// so we don't merge uncommitted local state.
|
||||
stage_all(&git, &repo_dir)?;
|
||||
|
||||
commit_now(&git, &repo_dir, &commit_message)?;
|
||||
|
||||
run_git(
|
||||
@@ -228,17 +234,51 @@ fn stage_all(git: &Path, repo: &Path) -> Result<()> {
|
||||
}
|
||||
|
||||
fn fetch_and_pull(git: &Path, repo: &Path, branch: &str) -> Result<()> {
|
||||
run_git(git, repo, &["fetch", "origin", branch])
|
||||
// Fetch all refs from origin (safe even if branch doesn't exist remotely)
|
||||
run_git(git, repo, &["fetch", "origin", "--prune"])
|
||||
.with_context(|| "Failed to fetch changes from remote")?;
|
||||
|
||||
let origin_ref = format!("origin/{branch}");
|
||||
let remote_has_branch = has_remote_branch(git, repo, branch);
|
||||
|
||||
// If the repo has no commits yet, prefer remote state and discard local
|
||||
// if the remote branch exists. Otherwise, keep local state and allow an
|
||||
// initial commit to be created and pushed.
|
||||
if !has_head(git, repo) {
|
||||
if remote_has_branch {
|
||||
run_git(git, repo, &["checkout", "-f", "-B", branch, &origin_ref])
|
||||
.with_context(|| "Failed to checkout remote branch over local state")?;
|
||||
run_git(git, repo, &["reset", "--hard", &origin_ref])
|
||||
.with_context(|| "Failed to hard reset to remote branch")?;
|
||||
run_git(git, repo, &["clean", "-fd"]).with_context(|| "Failed to clean untracked files")?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If we have local history and the remote branch exists, fast-forward.
|
||||
if remote_has_branch {
|
||||
run_git(
|
||||
git,
|
||||
repo,
|
||||
&["merge", "--ff-only", &format!("origin/{branch}")],
|
||||
&["merge", "--ff-only", &origin_ref],
|
||||
)
|
||||
.with_context(|| "Failed to merge remote changes")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_remote_branch(git: &Path, repo: &Path, branch: &str) -> bool {
|
||||
Command::new(git)
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["show-ref", "--verify", "--quiet", &format!("refs/remotes/origin/{}", branch)])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn has_head(git: &Path, repo: &Path) -> bool {
|
||||
Command::new(git)
|
||||
.arg("-C")
|
||||
@@ -280,3 +320,54 @@ fn commit_now(git: &Path, repo: &Path, msg: &str) -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sync_opts_validation_ok() {
|
||||
let remote = Some("git@github.com:user/repo.git".to_string());
|
||||
let branch = Some("main".to_string());
|
||||
let opts = SyncOpts {
|
||||
remote_url: &remote,
|
||||
branch: &branch,
|
||||
user_name: &None,
|
||||
user_email: &None,
|
||||
git_executable: &None,
|
||||
};
|
||||
assert!(opts.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_opts_validation_missing_fields() {
|
||||
let remote = None;
|
||||
let branch = None;
|
||||
let opts = SyncOpts {
|
||||
remote_url: &remote,
|
||||
branch: &branch,
|
||||
user_name: &None,
|
||||
user_email: &None,
|
||||
git_executable: &None,
|
||||
};
|
||||
assert!(opts.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_git_prefers_override_and_env() {
|
||||
// Override path wins
|
||||
let override_path = Some(PathBuf::from("/custom/git"));
|
||||
let got = resolve_git(override_path.as_ref()).unwrap();
|
||||
assert_eq!(got, PathBuf::from("/custom/git"));
|
||||
|
||||
// If no override, env var is used
|
||||
unsafe {
|
||||
env::set_var("GIT_EXECUTABLE", "/env/git");
|
||||
}
|
||||
let got_env = resolve_git(None).unwrap();
|
||||
assert_eq!(got_env, PathBuf::from("/env/git"));
|
||||
unsafe {
|
||||
env::remove_var("GIT_EXECUTABLE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
-16
@@ -1,29 +1,30 @@
|
||||
use anyhow::{Context, anyhow, bail};
|
||||
use anyhow::{anyhow, bail, Context};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::ProviderConfig;
|
||||
use crate::providers::git_sync::{sync_and_push, SyncOpts};
|
||||
use crate::providers::SecretProvider;
|
||||
use crate::providers::git_sync::{SyncOpts, sync_and_push};
|
||||
use crate::{
|
||||
ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use chacha20poly1305::aead::rand_core::RngCore;
|
||||
use chacha20poly1305::{
|
||||
Key, XChaCha20Poly1305, XNonce,
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
Key, XChaCha20Poly1305, XNonce,
|
||||
};
|
||||
use dialoguer::{Input, theme};
|
||||
use dialoguer::{theme, Input};
|
||||
use log::{debug, error};
|
||||
use serde::Deserialize;
|
||||
use theme::ColorfulTheme;
|
||||
use validator::Validate;
|
||||
|
||||
/// Configuration for the local file-based provider.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalProviderConfig {
|
||||
pub vault_path: String,
|
||||
@@ -40,6 +41,25 @@ impl Default for LocalProviderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// File-based vault provider with optional Git sync.
|
||||
///
|
||||
/// This provider stores encrypted envelopes in a per-user configuration
|
||||
/// directory via `confy`. A password is obtained from a configured password
|
||||
/// file or via an interactive prompt.
|
||||
///
|
||||
/// Example
|
||||
/// ```no_run
|
||||
/// use gman::providers::local::LocalProvider;
|
||||
/// use gman::providers::SecretProvider;
|
||||
/// use gman::config::Config;
|
||||
///
|
||||
/// let provider = LocalProvider;
|
||||
/// let cfg = Config::default();
|
||||
/// // Will prompt for a password when reading/writing secrets unless a
|
||||
/// // password file is configured.
|
||||
/// // provider.set_secret(&cfg, "MY_SECRET", "value")?;
|
||||
/// # Ok::<(), anyhow::Error>(())
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
|
||||
pub struct LocalProvider;
|
||||
|
||||
@@ -48,7 +68,7 @@ impl SecretProvider for LocalProvider {
|
||||
"LocalProvider"
|
||||
}
|
||||
|
||||
fn get_secret(&self, config: &Config, key: &str) -> Result<String> {
|
||||
fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result<String> {
|
||||
let vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
|
||||
let envelope = vault
|
||||
.get(key)
|
||||
@@ -61,7 +81,7 @@ impl SecretProvider for LocalProvider {
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
fn set_secret(&self, config: &Config, key: &str, value: &str) -> Result<()> {
|
||||
fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> {
|
||||
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
|
||||
if vault.contains_key(key) {
|
||||
error!(
|
||||
@@ -79,7 +99,7 @@ impl SecretProvider for LocalProvider {
|
||||
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault")
|
||||
}
|
||||
|
||||
fn update_secret(&self, config: &Config, key: &str, value: &str) -> Result<()> {
|
||||
fn update_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> {
|
||||
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
|
||||
|
||||
let password = get_password(config)?;
|
||||
@@ -119,7 +139,7 @@ impl SecretProvider for LocalProvider {
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn sync(&self, config: &mut Config) -> Result<()> {
|
||||
fn sync(&self, config: &mut ProviderConfig) -> Result<()> {
|
||||
let mut config_changed = false;
|
||||
|
||||
if config.git_branch.is_none() {
|
||||
@@ -139,9 +159,9 @@ impl SecretProvider for LocalProvider {
|
||||
let remote: String = Input::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt("Enter remote git URL to sync with")
|
||||
.validate_with(|s: &String| {
|
||||
Config {
|
||||
ProviderConfig {
|
||||
git_remote_url: Some(s.clone()),
|
||||
..Config::default()
|
||||
..ProviderConfig::default()
|
||||
}
|
||||
.validate()
|
||||
.map(|_| ())
|
||||
@@ -314,7 +334,7 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn get_password(config: &Config) -> Result<SecretString> {
|
||||
fn get_password(config: &ProviderConfig) -> Result<SecretString> {
|
||||
if let Some(password_file) = &config.password_file {
|
||||
let password = SecretString::new(
|
||||
fs::read_to_string(password_file)
|
||||
@@ -333,10 +353,10 @@ fn get_password(config: &Config) -> Result<SecretString> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::derive_key;
|
||||
use crate::providers::local::derive_key_with_params;
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use secrecy::SecretString;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_derive_key() {
|
||||
@@ -353,4 +373,26 @@ mod tests {
|
||||
let key = derive_key_with_params(&password, &salt, 10, 1, 1).unwrap();
|
||||
assert_eq!(key.as_slice().len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypto_roundtrip_local_impl() {
|
||||
let pw = SecretString::new("pw".into());
|
||||
let msg = "hello world";
|
||||
let env = encrypt_string(&pw, msg).unwrap();
|
||||
let out = decrypt_string(&pw, &env).unwrap();
|
||||
assert_eq!(out, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_password_reads_password_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("pw.txt");
|
||||
fs::write(&file, "secretpw\n").unwrap();
|
||||
let cfg = ProviderConfig {
|
||||
password_file: Some(file),
|
||||
..ProviderConfig::default()
|
||||
};
|
||||
let pw = get_password(&cfg).unwrap();
|
||||
assert_eq!(pw.expose_secret(), "secretpw");
|
||||
}
|
||||
}
|
||||
|
||||
+29
-6
@@ -1,19 +1,34 @@
|
||||
//! Secret provider trait and registry.
|
||||
//!
|
||||
//! Implementations provide storage/backends for secrets and a common
|
||||
//! interface used by the CLI.
|
||||
//!
|
||||
//! Selecting a provider from a string:
|
||||
//! ```
|
||||
//! use std::str::FromStr;
|
||||
//! use gman::providers::SupportedProvider;
|
||||
//!
|
||||
//! let p = SupportedProvider::from_str("local").unwrap();
|
||||
//! assert_eq!(p.to_string(), "local");
|
||||
//! ```
|
||||
mod git_sync;
|
||||
pub mod local;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::ProviderConfig;
|
||||
use crate::providers::local::LocalProvider;
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::Deserialize;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::str::FromStr;
|
||||
use thiserror::Error;
|
||||
|
||||
/// A secret storage backend capable of CRUD and sync, with optional
|
||||
/// update and listing
|
||||
pub trait SecretProvider {
|
||||
fn name(&self) -> &'static str;
|
||||
fn get_secret(&self, config: &Config, key: &str) -> Result<String>;
|
||||
fn set_secret(&self, config: &Config, key: &str, value: &str) -> Result<()>;
|
||||
fn update_secret(&self, _config: &Config, _key: &str, _value: &str) -> Result<()> {
|
||||
fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result<String>;
|
||||
fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()>;
|
||||
fn update_secret(&self, _config: &ProviderConfig, _key: &str, _value: &str) -> Result<()> {
|
||||
Err(anyhow!(
|
||||
"update secret not supported for provider {}",
|
||||
self.name()
|
||||
@@ -26,20 +41,28 @@ pub trait SecretProvider {
|
||||
self.name()
|
||||
))
|
||||
}
|
||||
fn sync(&self, config: &mut Config) -> Result<()>;
|
||||
fn sync(&self, config: &mut ProviderConfig) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Errors when parsing a provider identifier.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ParseProviderError {
|
||||
#[error("unsupported provider '{0}'")]
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
/// Registry of built-in providers.
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)]
|
||||
pub enum SupportedProvider {
|
||||
Local(LocalProvider),
|
||||
}
|
||||
|
||||
impl Default for SupportedProvider {
|
||||
fn default() -> Self {
|
||||
SupportedProvider::Local(LocalProvider)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SupportedProvider {
|
||||
type Err = ParseProviderError;
|
||||
|
||||
|
||||
@@ -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")),
|
||||
);
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
mod main_tests;
|
||||
+1
-1
@@ -1 +1 @@
|
||||
mod gman;
|
||||
mod cli_tests;
|
||||
+111
-11
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,3 +1,2 @@
|
||||
mod git_sync_tests;
|
||||
mod local_tests;
|
||||
mod provider_tests;
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
mod bin;
|
||||
mod config_tests;
|
||||
mod providers;
|
||||
mod bin;
|
||||
mod prop_crypto;
|
||||
Reference in New Issue
Block a user