52 Commits

Author SHA1 Message Date
Dark-Alex-17 47d2541b0f feat: Added typed errors to improve library DevX and removed now deprecated migrate command
Check / stable / fmt (push) Failing after 25s
Check / beta / clippy (push) Failing after 44s
Check / stable / clippy (push) Failing after 43s
Check / nightly / doc (push) Failing after 42s
Check / 1.89.0 / check (push) Failing after 45s
Test Suite / ubuntu / beta (push) Failing after 44s
Test Suite / ubuntu / stable (push) Failing after 44s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m17s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-06-02 12:16:11 -06:00
Dark-Alex-17 424aed0315 build: updated dependencies to address new security vulnerabilities 2026-06-02 12:15:30 -06:00
Dark-Alex-17 38c5d5509a build: updated dependencies
Check / stable / fmt (push) Failing after 23s
Check / beta / clippy (push) Failing after 42s
Check / stable / clippy (push) Failing after 42s
Check / nightly / doc (push) Failing after 40s
Check / 1.89.0 / check (push) Failing after 42s
Test Suite / ubuntu / beta (push) Failing after 41s
Test Suite / ubuntu / stable (push) Failing after 43s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m9s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-04-21 09:18:42 -06:00
Dark-Alex-17 067e16cb8b docs: Added in forgotten gopass configuration link
Check / stable / fmt (push) Failing after 23s
Check / beta / clippy (push) Failing after 41s
Check / stable / clippy (push) Failing after 41s
Check / nightly / doc (push) Failing after 39s
Check / 1.89.0 / check (push) Failing after 42s
Test Suite / ubuntu / beta (push) Failing after 41s
Test Suite / ubuntu / stable (push) Failing after 42s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2026-04-21 09:12:56 -06:00
github-actions[bot] 9f63ee8265 bump: version 0.4.0 → 0.4.1 [skip ci] 2026-03-20 22:04:37 +00:00
Dark-Alex-17 6cba3d6d0b feat: Upgraded aws-lc-sys version to address high severity CWE-295
Check / stable / fmt (push) Failing after 25s
Check / beta / clippy (push) Failing after 39s
Check / stable / clippy (push) Failing after 40s
Check / nightly / doc (push) Failing after 41s
Check / 1.89.0 / check (push) Failing after 41s
Test Suite / ubuntu / beta (push) Failing after 43s
Test Suite / ubuntu / stable (push) Failing after 42s
Test Suite / ubuntu / stable / coverage (push) Failing after 59s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-03-20 16:03:49 -06:00
Dark-Alex-17 b2a51dc1b1 docs: Cleaned up README formatting a tad (80 character column length)
Check / stable / fmt (push) Has been cancelled
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2026-03-09 17:35:22 -06:00
github-actions[bot] cc44fca54e bump: version 0.3.0 → 0.4.0 [skip ci] 2026-03-09 23:06:23 +00:00
Dark-Alex-17 9a678ae67d build: Updated dependencies to address security issues
Check / stable / fmt (push) Has been cancelled
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2026-03-09 16:57:29 -06:00
Dark-Alex-17 66b950991c feat: Added 1password support
Check / stable / fmt (push) Has been cancelled
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2026-03-09 16:33:57 -06:00
Dark-Alex-17 e8e0bd02e9 docs: created an authorship policy and pull request template to require disclosure of AI coding assistance for all contributions 2026-02-24 17:48:28 -07:00
Dark-Alex-17 ed5a7308be build: Migrated from Makefile to justfile
Check / stable / fmt (push) Successful in 9m56s
Check / beta / clippy (push) Failing after 40s
Check / stable / clippy (push) Failing after 39s
Check / nightly / doc (push) Failing after 36s
Check / 1.89.0 / check (push) Failing after 39s
Test Suite / ubuntu / beta (push) Failing after 39s
Test Suite / ubuntu / stable (push) Failing after 38s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m4s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-02-02 10:40:52 -07:00
Dark-Alex-17 044d5960eb feat: sort local keys alphabetically when listing them
Check / stable / fmt (push) Has been cancelled
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2026-02-02 10:36:56 -07:00
github-actions[bot] c0aa379b20 bump: version 0.2.3 → 0.3.0 [skip ci] 2026-02-02 01:08:03 +00:00
Dark-Alex-17 f9fd9692aa build: Modified integration tests so they don't run when cross-compiling to non-x86 systems
Check / stable / fmt (push) Successful in 9m54s
Check / beta / clippy (push) Failing after 39s
Check / stable / clippy (push) Failing after 40s
Check / nightly / doc (push) Failing after 37s
Check / 1.89.0 / check (push) Failing after 38s
Test Suite / ubuntu / beta (push) Failing after 38s
Test Suite / ubuntu / stable (push) Failing after 39s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m3s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-02-01 18:03:51 -07:00
Dark-Alex-17 2615b23d6e test: Removed deprecated function calls from cli_tests module and sped up proptests
Check / stable / fmt (push) Successful in 9m55s
Check / beta / clippy (push) Failing after 38s
Check / stable / clippy (push) Failing after 39s
Check / nightly / doc (push) Failing after 37s
Check / 1.89.0 / check (push) Failing after 38s
Test Suite / ubuntu / beta (push) Failing after 38s
Test Suite / ubuntu / stable (push) Failing after 39s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m28s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-02-01 17:14:24 -07:00
Dark-Alex-17 628a13011e build: upgraded to the most recent Azure SDK version 2026-02-01 16:44:28 -07:00
Dark-Alex-17 cff4420ee0 fix: Upgraded AWS dependencies to address CWE-20 2026-02-01 16:15:41 -07:00
Dark-Alex-17 9944e29ef0 fix: A critical security flaw was discovered that essentially had all local secrets be encrypted with an all-zero key 2026-02-01 16:15:13 -07:00
Dark-Alex-17 c95bae1761 fix: Addressed XNonce::from_slice deprecation warning 2026-02-01 14:48:37 -07:00
Dark-Alex-17 21da7b782e fix: Secrets are now stored exactly as passed without newlines stripped 2026-02-01 14:47:43 -07:00
Dark-Alex-17 d038930ce5 docs: fixed a typo in the mac/linux install script command
Check / stable / fmt (push) Has been cancelled
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2025-11-07 11:39:04 -07:00
github-actions[bot] f0fc829a73 chore: bump Cargo.toml to 0.2.3 2025-10-14 23:32:36 +00:00
github-actions[bot] ba0f108aa8 bump: version 0.2.2 → 0.2.3 [skip ci] 2025-10-14 23:32:32 +00:00
Dark-Alex-17 6daa6fd2f2 refactor: Refactored the library for gman so that it dynamically names config and password files to be used across any application 2025-10-14 17:12:43 -06:00
Dark-Alex-17 5fa4dbfe89 Merge remote-tracking branch 'origin/main' 2025-10-07 10:59:00 -06:00
Dark-Alex-17 bdcd496046 docs: fixed typo in code of conduct 2025-10-07 10:58:52 -06:00
github-actions[bot] e37b80a262 bump: version 0.2.1 → 0.2.2 [skip ci] 2025-09-30 22:03:17 +00:00
Dark-Alex-17 3ce62c272e build: Updated changelog format 2025-09-30 15:42:41 -06:00
Dark-Alex-17 21b771507c Merge remote-tracking branch 'origin/main' 2025-09-30 15:40:36 -06:00
Dark-Alex-17 508c8b7feb style: Reformatted code 2025-09-30 15:40:27 -06:00
github-actions[bot] 33a889fa67 chore: bump Cargo.toml to 0.2.2 2025-09-30 21:37:14 +00:00
github-actions[bot] 7ddb7812fc bump: version 0.2.1 → 0.2.2 [skip ci] 2025-09-30 21:37:04 +00:00
Dark-Alex-17 9e11648a7c refactor: Environment variable interpolation in config file works globally, not based on type 2025-09-30 15:35:48 -06:00
github-actions[bot] ed79af2a8a chore: bump Cargo.toml to 0.2.1 2025-09-30 17:44:19 +00:00
github-actions[bot] 443fbcf305 bump: version 0.2.0 → 0.2.1 [skip ci] 2025-09-30 17:44:08 +00:00
Dark-Alex-17 78d7e90e68 feat: Environment variable interpolation in the Gman configuration file 2025-09-30 11:10:20 -06:00
Dark-Alex-17 01d4819160 fix: Corrected tab completions for the provider flag 2025-09-30 09:25:29 -06:00
github-actions[bot] e200a32f5a bump: version 0.1.0 → 0.2.0 [skip ci] 2025-09-30 03:56:50 +00:00
Dark-Alex-17 008b33b044 docs: Updated changelog once more 2025-09-29 21:35:02 -06:00
Alex Clarke f35afac20f docs: Update Changeling changelog 2025-09-29 18:48:09 -07:00
Dark-Alex-17 262a3d6435 build: Fixed build dependencies 2025-09-29 18:16:39 -06:00
Dark-Alex-17 eb9e671818 Merge branch 'main' of github.com:Dark-Alex-17/gman 2025-09-29 18:15:44 -06:00
Dark-Alex-17 efc8af2c93 docs: Updated the gopass provider docs 2025-09-29 18:15:30 -06:00
Dark-Alex-17 3d38ac9b51 docs: Added gopass docs to README 2025-09-29 17:55:06 -06:00
github-actions[bot] 8d40c3773f chore: bump Cargo.toml to 0.2.0 2025-09-29 23:52:24 +00:00
github-actions[bot] 16ce245218 bump: version 0.1.0 → 0.2.0 [skip ci] 2025-09-29 23:52:12 +00:00
Dark-Alex-17 a64f4dbf79 test: Added tests for the new gopass provider 2025-09-29 17:28:04 -06:00
Dark-Alex-17 1b83d9b199 feat: gopass support 2025-09-29 16:34:51 -06:00
Dark-Alex-17 f006503736 feat: Added command aliases to make the CLI more universal 2025-09-29 16:30:39 -06:00
Dark-Alex-17 9abd2f88cf feat: Added dynamic tab completions for the profile, providers, and the secrets in any given secret manager 2025-09-29 16:30:16 -06:00
Dark-Alex-17 29acad5eed feat: Users can now specify a default provider to use with each run config, so they don't need to explicitly specify which to use when wanting to run different applications. 2025-09-29 15:18:56 -06:00
31 changed files with 3463 additions and 1404 deletions
@@ -0,0 +1,11 @@
### AI assistance (if any):
- List tools here and files touched by them
### Authorship & Understanding
- [ ] I wrote or heavily modified this code myself
- [ ] I understand how it works end-to-end
- [ ] I can maintain this code in the future
- [ ] No undisclosed AI-generated code was used
- [ ] If AI assistance was used, it is documented below
+52 -1
View File
@@ -5,7 +5,58 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.0.1] - 2025-09-10 ## v0.4.1 (2026-03-20)
### Feat
- Upgraded aws-lc-sys version to address high severity CWE-295
## v0.4.0 (2026-03-09)
### Feat
- Added 1password support
- sort local keys alphabetically when listing them
## v0.3.0 (2026-02-02)
### Fix
- Upgraded AWS dependencies to address CWE-20
- A critical security flaw was discovered that essentially had all local secrets be encrypted with an all-zero key
- Addressed XNonce::from_slice deprecation warning
- Secrets are now stored exactly as passed without newlines stripped
## v0.2.3 (2025-10-14)
### Refactor
- Refactored the library for gman so that it dynamically names config and password files to be used across any application
## v0.2.2 (2025-09-30)
### Refactor
- Environment variable interpolation in config file works globally, not based on type
## v0.2.1 (2025-09-30)
### Feat
- Environment variable interpolation in the Gman configuration file
### Fix
- Corrected tab completions for the provider flag
## v0.2.0 (2025-09-30)
### Feat
- gopass support
- Added command aliases to make the CLI more universal
- Added dynamic tab completions for the profile, providers, and the secrets in any given secret manager
- Users can now specify a default provider to use with each run config, so they don't need to explicitly specify which to use when wanting to run different applications.
## v0.1.0 (2025-09-17) ## v0.1.0 (2025-09-17)
+1 -1
View File
@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at reported to the community leaders responsible for enforcement at
d4udts@gmail.com. alex.j.tusa@gmail.com.
All complaints will be reviewed and investigated promptly and fairly. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the All community leaders are obligated to respect the privacy and security of the
+9 -1
View File
@@ -48,7 +48,8 @@ cz commit
1. Clone this repo 1. Clone this repo
2. Run `cargo test` to set up hooks 2. Run `cargo test` to set up hooks
3. Make changes 3. Make changes
4. Run the application using `make run` or `cargo run` 4. Run the application using `just run` or `just run`
- Install `just` (`cargo install just`) if you haven't already to use the [justfile](./justfile) in this project.
5. Commit changes. This will trigger pre-commit hooks that will run format, test and lint. If there are errors or 5. Commit changes. This will trigger pre-commit hooks that will run format, test and lint. If there are errors or
warnings from Clippy, please fix them. warnings from Clippy, please fix them.
6. Push your code to a new branch named after the feature/bug/etc. you're adding. This will trigger pre-push hooks that 6. Push your code to a new branch named after the feature/bug/etc. you're adding. This will trigger pre-push hooks that
@@ -75,6 +76,13 @@ Then, you can run workflows locally without having to commit and see if the GitH
act -W .github/workflows/release.yml --input_type bump=minor act -W .github/workflows/release.yml --input_type bump=minor
``` ```
## Authorship Policy
All code in this repository is written and reviewed by humans. AI-generated code (e.g., Copilot, ChatGPT,
Claude, etc.) is not permitted unless explicitly disclosed and approved.
Submissions must certify that the contributor understands and can maintain the code they submit.
## Questions? Reach out to me! ## Questions? Reach out to me!
If you encounter any questions while developing G-Man, please don't hesitate to reach out to me at If you encounter any questions while developing G-Man, please don't hesitate to reach out to me at
alex.j.tusa@gmail.com. I'm happy to help contributors in any way I can, regardless of if they're new or experienced! alex.j.tusa@gmail.com. I'm happy to help contributors in any way I can, regardless of if they're new or experienced!
Generated
+1120 -830
View File
File diff suppressed because it is too large Load Diff
+21 -11
View File
@@ -1,10 +1,16 @@
[package] [package]
name = "gman" name = "gman"
version = "0.1.0" version = "0.4.1"
edition = "2024" edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"] authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "Universal command line secret management and injection tool" description = "Universal command line secret management and injection tool"
keywords = ["cli", "secrets-manager", "secret-injection", "command-runner", "vault"] keywords = [
"cli",
"secrets-manager",
"secret-injection",
"command-runner",
"vault",
]
documentation = "https://github.com/Dark-Alex-17/gman" documentation = "https://github.com/Dark-Alex-17/gman"
repository = "https://github.com/Dark-Alex-17/gman" repository = "https://github.com/Dark-Alex-17/gman"
homepage = "https://github.com/Dark-Alex-17/gman" homepage = "https://github.com/Dark-Alex-17/gman"
@@ -25,8 +31,8 @@ clap = { version = "4.5.47", features = [
"env", "env",
"wrap_help", "wrap_help",
] } ] }
clap_complete = "4.5.57" clap_complete = { version = "4.5.57", features = ["unstable-dynamic"] }
confy = { version = "1.0.0", default-features = false, features = [ confy = { version = "2.0.0", default-features = false, features = [
"yaml_conf", "yaml_conf",
] } ] }
crossterm = "0.29.0" crossterm = "0.29.0"
@@ -47,18 +53,22 @@ indoc = "2.0.6"
regex = "1.11.2" regex = "1.11.2"
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
tempfile = "3.22.0" tempfile = "3.22.0"
aws-sdk-secretsmanager = "1.88.0" aws-sdk-secretsmanager = "1.98.0"
tokio = { version = "1.47.1", features = ["full"] } tokio = { version = "1.47.1", features = ["full"] }
aws-config = { version = "1.8.6", features = ["behavior-version-latest"] } aws-config = { version = "1.8.12", features = ["behavior-version-latest"] }
async-trait = "0.1.89" async-trait = "0.1.89"
futures = "0.3.31" futures = "0.3.31"
gcloud-sdk = { version = "0.28.1", features = [ gcloud-sdk = { version = "0.28.5", features = [
"google-cloud-secretmanager-v1", "google-cloud-secretmanager-v1",
] } ] }
crc32c = "0.6.8" crc32c = "0.6.8"
azure_identity = "0.27.0" azure_core = "0.31.0"
azure_security_keyvault_secrets = "0.6.0" azure_identity = "0.31.0"
aws-lc-sys = { version = "0.31.0", features = ["bindgen"] } azure_security_keyvault_secrets = "0.10.0"
aws-lc-sys = { version = "0.39.0", features = ["bindgen"] }
which = "8.0.0"
once_cell = "1.21.3"
thiserror = "2"
[target.'cfg(all(target_os="linux", target_env="musl"))'.dependencies] [target.'cfg(all(target_os="linux", target_env="musl"))'.dependencies]
openssl = { version = "0.10", features = ["vendored"] } openssl = { version = "0.10", features = ["vendored"] }
@@ -71,7 +81,7 @@ pretty_assertions = "1.4.1"
proptest = "1.5.0" proptest = "1.5.0"
assert_cmd = "2.0.16" assert_cmd = "2.0.16"
predicates = "3.1.2" predicates = "3.1.2"
serial_test = "3.2.0"
[[bin]] [[bin]]
bench = false bench = false
-40
View File
@@ -1,40 +0,0 @@
#!make
default: run
.PHONY: test test-cov build run lint lint-fix fmt minimal-versions analyze release delete-tag
test:
@cargo test --all
## Run all tests with coverage - `cargo install cargo-tarpaulin`
test-cov:
@cargo tarpaulin
build: test
@cargo build --release
run:
@CARGO_INCREMENTAL=1 cargo fmt && make lint && cargo run
lint:
@find . | grep '\.\/src\/.*\.rs$$' | xargs touch && CARGO_INCREMENTAL=0 cargo clippy --all-targets --workspace
lint-fix:
@cargo fix
fmt:
@cargo fmt
minimal-versions:
@cargo +nightly update -Zdirect-minimal-versions
## Analyze for unsafe usage - `cargo install cargo-geiger`
analyze:
@cargo geiger
release:
@git tag -a ${V} -m "Release ${V}" && git push origin ${V}
delete-tag:
@git tag -d ${V} && git push --delete origin ${V}
+153 -16
View File
@@ -2,7 +2,6 @@
![Check](https://github.com/Dark-Alex-17/gman/actions/workflows/check.yml/badge.svg) ![Check](https://github.com/Dark-Alex-17/gman/actions/workflows/check.yml/badge.svg)
![Test](https://github.com/Dark-Alex-17/gman/actions/workflows/test.yml/badge.svg) ![Test](https://github.com/Dark-Alex-17/gman/actions/workflows/test.yml/badge.svg)
![LOC](https://tokei.rs/b1/github/Dark-Alex-17/gman?category=code)
[![crates.io link](https://img.shields.io/crates/v/gman.svg)](https://crates.io/crates/gman) [![crates.io link](https://img.shields.io/crates/v/gman.svg)](https://crates.io/crates/gman)
![Release](https://img.shields.io/github/v/release/Dark-Alex-17/gman?color=%23c694ff) ![Release](https://img.shields.io/github/v/release/Dark-Alex-17/gman?color=%23c694ff)
![Crate.io downloads](https://img.shields.io/crates/d/gman?label=Crate%20downloads) ![Crate.io downloads](https://img.shields.io/crates/d/gman?label=Crate%20downloads)
@@ -14,8 +13,8 @@ files or sprinkling environment variables everywhere.
## Overview ## Overview
`gman` acts as a universal wrapper for any command that needs credentials. Store your secretsAPI tokens, passwords, `gman` acts as a universal wrapper for any command that needs credentials. Store your secrets (e.g. API tokens, passwords,
certswith a provider, then either fetch them directly or run your command through `gman` to inject what it needs as certs, etc.) 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. environment variables, flags, or file content.
## Quick Examples: Before vs After ## Quick Examples: Before vs After
@@ -89,12 +88,16 @@ gman aws sts get-caller-identity
- [Features](#features) - [Features](#features)
- [Installation](#installation) - [Installation](#installation)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Environment Variable Interpolation](#environment-variable-interpolation)
- [Providers](#providers) - [Providers](#providers)
- [Local](#provider-local) - [Local](#provider-local)
- [AWS Secrets Manager](#provider-aws_secrets_manager) - [AWS Secrets Manager](#provider-aws_secrets_manager)
- [GCP Secret Manager](#provider-gcp_secret_manager) - [GCP Secret Manager](#provider-gcp_secret_manager)
- [Azure Key Vault](#provider-azure_key_vault) - [Azure Key Vault](#provider-azure_key_vault)
- [Gopass](#provider-gopass)
- [1Password](#provider-one_password)
- [Run Configurations](#run-configurations) - [Run Configurations](#run-configurations)
- [Specifying a Default Provider per Run Config](#specifying-a-default-provider-per-run-config)
- [Environment Variable Secret Injection](#environment-variable-secret-injection) - [Environment Variable Secret Injection](#environment-variable-secret-injection)
- [Inject Secrets via Command-Line Flags](#inject-secrets-via-command-line-flags) - [Inject Secrets via Command-Line Flags](#inject-secrets-via-command-line-flags)
- [Inject Secrets into Files](#inject-secrets-into-files) - [Inject Secrets into Files](#inject-secrets-into-files)
@@ -139,7 +142,7 @@ You can use the following command to run a bash script that downloads and instal
OS (Linux/MacOS) and architecture (x86_64/arm64): OS (Linux/MacOS) and architecture (x86_64/arm64):
```shell ```shell
curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/gman/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/gman/main/install_gman.sh | bash
``` ```
#### Windows/Linux/MacOS (`PowerShell`) #### Windows/Linux/MacOS (`PowerShell`)
@@ -174,6 +177,22 @@ To use a binary from the releases page on Linux/MacOS, do the following:
3. Extract the binary with `tar -C /usr/local/bin -xzf gman-<arch>.tar.gz` (Note: This may require `sudo`) 3. Extract the binary with `tar -C /usr/local/bin -xzf gman-<arch>.tar.gz` (Note: This may require `sudo`)
4. Now you can run `gman`! 4. Now you can run `gman`!
### Enable Tab Completion
`gman` supports shell tab completion for `bash`, `zsh`, and `fish`. To enable it, run the following command for your
shell:
```shell
# Bash
echo 'source <(COMPLETE=bash gman)' >> ~/.bashrc
# Zsh
echo 'source <(COMPLETE=zsh gman)' >> ~/.zshrc
# Fish
echo 'COMPLETE=fish gman | source' >> ~/.config/fish/config.fish
```
Then restart your shell or `source` the appropriate config file.
## Configuration ## Configuration
`gman` reads a YAML configuration file located at an OS-specific path: `gman` reads a YAML configuration file located at an OS-specific path:
@@ -224,6 +243,28 @@ providers:
run_configs: [] run_configs: []
``` ```
### Environment Variable Interpolation
The config file supports environment variable interpolation using `${VAR_NAME}` syntax. For example, to use an
AWS profile from your environment:
```yaml
providers:
- name: aws
type: aws_secrets_manager
aws_profile: ${AWS_PROFILE} # Uses the AWS_PROFILE env var
aws_region: us-east-1
```
Or to set a default profile to use when `AWS_PROFILE` is unset:
```yaml
providers:
- name: aws
type: aws_secrets_manager
aws_profile: ${AWS_PROFILE:-default} # Uses 'default' if AWS_PROFILE is unset
aws_region: us-east-1
```
## 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 CLI and config format are designed to be extensible so new providers can be encrypted file on your filesystem. The CLI and config format are designed to be extensible so new providers can be
@@ -238,17 +279,18 @@ documented and added without breaking existing setups. The following table shows
| 🚫 | Won't Add | | 🚫 | Won't Add |
| Provider Name | Status | Configuration Docs | Comments | | Provider Name | Status | Configuration Docs | Comments |
|--------------------------------------------------------------------------------------------------------------------------|--------|------------------------------------------------------|--------------------------------------------| |-------------------------------------------------------------------------------------------------|--------|------------------------------------------------------|--------------------------------------------|
| `local` | ✅ | [Local](#provider-local) | | | `local` | ✅ | [Local](#provider-local) | |
| [`aws_secrets_manager`](https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html) | ✅ | [AWS Secrets Manager](#provider-aws_secrets_manager) | | | [`aws_secrets_manager`](https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html) | ✅ | [AWS Secrets Manager](#provider-aws_secrets_manager) | |
| [`hashicorp_vault`](https://www.hashicorp.com/en/products/vault) | 🕒 | | | | [`hashicorp_vault`](https://www.hashicorp.com/en/products/vault) | 🕒 | | |
| [`azure_key_vault`](https://azure.microsoft.com/en-us/products/key-vault/) | ✅ | [Azure Key Vault](#provider-azure_key_vault) | | | [`azure_key_vault`](https://azure.microsoft.com/en-us/products/key-vault/) | ✅ | [Azure Key Vault](#provider-azure_key_vault) | |
| [`gcp_secret_manager`](https://cloud.google.com/security/products/secret-manager?hl=en) | ✅ | [GCP Secret Manager](#provider-gcp_secret_manager) | | | [`gcp_secret_manager`](https://cloud.google.com/security/products/secret-manager?hl=en) | ✅ | [GCP Secret Manager](#provider-gcp_secret_manager) | |
| [`1password`](https://1password.com/) | 🕒 | | | | [`gopass`](https://www.gopass.pw/) | ✅ | [Gopass](#provider-gopass) | |
| [`bitwarden`](https://bitwarden.com/) | 🕒 | | | | [`1password`](https://1password.com/) | ✅ | [1Password](#provider-one_password) | |
| [`dashlane`](https://www.dashlane.com/) | 🕒 | | Waiting for CLI support for adding secrets | | [`bitwarden`](https://bitwarden.com/) | 🕒 | | |
| [`lastpass`](https://www.lastpass.com/) | 🕒 | | | | [`dashlane`](https://www.dashlane.com/) | 🕒 | | Waiting for CLI support for adding secrets |
| [`lastpass`](https://www.lastpass.com/) | 🕒 | | |
### Provider: `local` ### Provider: `local`
@@ -388,6 +430,62 @@ Important notes:
- Ensure your identity has the necessary Key Vault permissions (RBAC such as `Key Vault Secrets User`/`Administrator`, - Ensure your identity has the necessary Key Vault permissions (RBAC such as `Key Vault Secrets User`/`Administrator`,
or appropriate access policies) for get/set/list/delete. or appropriate access policies) for get/set/list/delete.
### Provider: `gopass`
The `gopass` provider uses [gopass](https://www.gopass.pw/) as the backing storage location for secrets.
- Optional: `store` (string) to specify a particular gopass store if you have multiple.
Configuration example:
```yaml
default_provider: gopass
providers:
- name: gopass
type: gopass
store: my-store # Optional; if omitted, uses the default configured gopass store
```
Important notes:
- Ensure `gopass` is installed and initialized on your system.
- Secrets are managed using gopass's native commands; `gman` acts as a wrapper to interface with gopass.
- Updates overwrite existing secrets
- If no store is specified, the default gopass store is used and `gman sync` will sync with all configured stores.
### Provider: `one_password`
The `one_password` provider uses the [1Password CLI (`op`)](https://developer.1password.com/docs/cli/) as the backing
storage location for secrets.
- Optional: `vault` (string) to specify which 1Password vault to use. If omitted, the default vault is used.
- Optional: `account` (string) to specify which 1Password account to use. Useful if you have multiple accounts. If
omitted, the default signed-in account is used.
Configuration example:
```yaml
default_provider: op
providers:
- name: op
type: one_password
vault: Production # Optional; if omitted, uses the default vault
account: my.1password.com # Optional; if omitted, uses the default account
```
Authentication:
- **Interactive**: Run `op signin` to sign in interactively.
- **Service Account**: Set the `OP_SERVICE_ACCOUNT_TOKEN` environment variable for non-interactive/CI usage.
- **Desktop App Integration**: If the 1Password desktop app is installed and configured, the CLI can use biometric
authentication (Touch ID, Windows Hello, etc.).
Important notes:
- Ensure the 1Password CLI (`op`) is installed on your system. Install instructions are at
https://developer.1password.com/docs/cli/get-started/.
- Secrets are stored as 1Password Password items. The item title is the secret name and the `password` field holds the
secret value.
- **Deletions are permanent. Deleted items are not archived.**
- `add` creates a new Password item. If an item with the same title already exists in the vault, `op` will create a
duplicate. Use `update` to change an existing secret value.
- `list` returns the titles of all items in the configured vault.
## Run Configurations ## Run Configurations
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
@@ -404,6 +502,45 @@ will error out and report that it could not find the run config with that name.
You can manually specify which run configuration to use with the `--profile` flag. Again, if no profile is found with You can manually specify which run configuration to use with the `--profile` flag. Again, if no profile is found with
that name, `gman` will error out. that name, `gman` will error out.
### Specifying a Default Provider per Run Config
All run configs also support the `provider` field, which lets you override the default provider for that specific
profile. This is useful if you have multiple providers configured and want to use a different one for a specific command
, but that provider may not be the `default_provider`, and you don't want to have to specify `--provider` on the command
line every time.
For Example:
```yaml
default_provider: local
run_configs:
# `gman aws ...` uses the `aws` provider instead of `local` if no
# `--provider` is given.
- name: aws
# Can be overridden by explicitly specifying a `--provider`
provider: aws
secrets:
- DB_USERNAME
- DB_PASSWORD
# `gman docker ...` uses the default_provider `local` because no
# `provider` is specified.
- name: docker
secrets:
- MY_APP_API_KEY
- MY_APP_DB_PASSWORD
# `gman managarr ...` uses the `local` provider; This is useful
# if you change the default provider to something else.
- name: managarr
provider: local
secrets:
- RADARR_API_KEY
- SONARR_API_KEY
files:
- /home/user/.config/managarr/config.yml
```
**Important Note:** Any run config with a `provider` field can be overridden by specifying `--provider` on the command
line.
### Environment Variable Secret Injection ### Environment Variable Secret Injection
By default, secrets are injected as environment variables. The two required fields are `name` and `secrets`. By default, secrets are injected as environment variables. The two required fields are `name` and `secrets`.
@@ -556,7 +693,7 @@ gman managarr
### Multiple Providers and Switching ### Multiple Providers and Switching
You can define multiple providerseven multiple of the same typeand switch between them per command. 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`. Example: two AWS Secrets Manager providers named `lab` and `prod`.
+35
View File
@@ -0,0 +1,35 @@
# List all recipes
default:
@just --list
# Format all files
[group: 'style']
fmt:
@cargo fmt --all
alias clippy := lint
# Run Clippy to inspect all files
[group: 'style']
lint:
@cargo clippy --all
alias clippy-fix := lint-fix
# Automatically fix clippy issues where possible
[group: 'style']
lint-fix:
@cargo fix
# Run all tests
[group: 'test']
test:
@cargo test --all
# Build and run the binary for the current system
run:
@cargo run
# Build the project for the current system architecture
[group: 'build']
[arg('build_type', pattern="debug|release")]
build build_type='debug':
@cargo build {{ if build_type == "release" { "--release" } else { "" } }}
+205 -35
View File
@@ -1,21 +1,22 @@
use crate::command::preview_command; use crate::command::preview_command;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use clap_complete::CompletionCandidate;
use futures::future::join_all; use futures::future::join_all;
use gman::config::{Config, RunConfig}; use gman::config::{Config, RunConfig, load_config};
use gman::providers::SecretProvider;
use log::{debug, error}; use log::{debug, error};
use regex::Regex; use regex::Regex;
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::OsString; use std::ffi::{OsStr, OsString};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use tokio::runtime::Handle;
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 async fn wrap_and_run_command( pub async fn wrap_and_run_command(
secrets_provider: &mut dyn SecretProvider, provider: Option<String>,
config: &Config, config: &Config,
tokens: Vec<OsString>, tokens: Vec<OsString>,
profile_name: Option<String>, profile_name: Option<String>,
@@ -36,6 +37,9 @@ pub async fn wrap_and_run_command(
.find(|c| c.name.as_deref() == Some(run_config_profile_name)) .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 mut provider_config =
config.extract_provider_config(provider.or(run_cfg.provider.clone()))?;
let secrets_provider = provider_config.extract_provider();
let secrets_result_futures = run_cfg let secrets_result_futures = run_cfg
.secrets .secrets
.as_ref() .as_ref()
@@ -163,7 +167,7 @@ fn generate_files_secret_injections(
secrets: HashMap<&str, String>, secrets: HashMap<&str, String>,
run_config: &RunConfig, run_config: &RunConfig,
) -> Result<Vec<(PathBuf, String, String)>> { ) -> Result<Vec<(PathBuf, String, String)>> {
let re = Regex::new(r"\{\{(.+)\}\}")?; let re = Regex::new(r"\{\{(.+)}}")?;
let mut results = Vec::new(); let mut results = Vec::new();
for file in run_config for file in run_config
.files .files
@@ -251,34 +255,82 @@ pub fn parse_args(
Ok(args) Ok(args)
} }
pub fn run_config_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
match load_config(true) {
Ok(config) => {
if let Some(run_configs) = config.run_configs {
run_configs
.iter()
.filter(|rc| {
rc.name
.as_ref()
.expect("run config has no name")
.starts_with(&*cur)
})
.map(|rc| {
CompletionCandidate::new(rc.name.as_ref().expect("run config has no name"))
})
.collect()
} else {
vec![]
}
}
Err(_) => vec![],
}
}
pub fn provider_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
match load_config(true) {
Ok(config) => config
.providers
.iter()
.filter(|pc| {
pc.name
.as_ref()
.expect("run config has no name")
.starts_with(&*cur)
})
.map(|pc| CompletionCandidate::new(pc.name.as_ref().expect("provider has no name")))
.collect(),
Err(_) => vec![],
}
}
pub fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
match load_config(true) {
Ok(config) => {
let mut provider_config = match config.extract_provider_config(None) {
Ok(pc) => pc,
Err(_) => return vec![],
};
let secrets_provider = provider_config.extract_provider();
let h = Handle::current();
tokio::task::block_in_place(|| h.block_on(secrets_provider.list_secrets()))
.unwrap_or_default()
.into_iter()
.filter(|s| s.starts_with(&*cur))
.map(CompletionCandidate::new)
.collect()
}
Err(_) => vec![],
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::cli::generate_files_secret_injections; use crate::cli::generate_files_secret_injections;
use gman::config::get_config_file_path;
use gman::config::{Config, RunConfig}; use gman::config::{Config, RunConfig};
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use serial_test::serial;
use std::collections::HashMap; use std::collections::HashMap;
use std::env as std_env;
use std::ffi::OsString; use std::ffi::OsString;
use tempfile::tempdir;
struct DummyProvider;
#[async_trait::async_trait]
impl SecretProvider for DummyProvider {
fn name(&self) -> &'static str {
"Dummy"
}
async fn get_secret(&self, key: &str) -> Result<String> {
Ok(format!("{}_VAL", key))
}
async fn set_secret(&self, _key: &str, _value: &str) -> Result<()> {
Ok(())
}
async fn delete_secret(&self, _key: &str) -> Result<()> {
Ok(())
}
async fn sync(&mut self) -> Result<()> {
Ok(())
}
}
#[test] #[test]
fn test_generate_files_secret_injections() { fn test_generate_files_secret_injections() {
@@ -290,6 +342,7 @@ mod tests {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["testing/SOME-secret".to_string()]), secrets: Some(vec!["testing/SOME-secret".to_string()]),
files: Some(vec![file_path.clone()]), files: Some(vec![file_path.clone()]),
flag: None, flag: None,
@@ -309,6 +362,7 @@ mod tests {
fn test_parse_args_insert_and_append() { fn test_parse_args_insert_and_append() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("docker".into()), name: Some("docker".into()),
provider: None,
secrets: Some(vec!["api_key".into()]), secrets: Some(vec!["api_key".into()]),
files: None, files: None,
flag: Some("-e".into()), flag: Some("-e".into()),
@@ -347,10 +401,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_wrap_and_run_command_no_profile() { async fn test_wrap_and_run_command_no_profile() {
let cfg = Config::default(); let cfg = Config::default();
let mut dummy = DummyProvider;
let prov: &mut dyn SecretProvider = &mut dummy;
let tokens = vec![OsString::from("echo"), OsString::from("hi")]; let tokens = vec![OsString::from("echo"), OsString::from("hi")];
let err = wrap_and_run_command(prov, &cfg, tokens, None, true) let err = wrap_and_run_command(None, &cfg, tokens, None, true)
.await .await
.unwrap_err(); .unwrap_err();
assert!(err.to_string().contains("No run profile found")); assert!(err.to_string().contains("No run profile found"));
@@ -361,6 +413,7 @@ mod tests {
// Create a config with a matching run profile for command "echo" // Create a config with a matching run profile for command "echo"
let run_cfg = RunConfig { let run_cfg = RunConfig {
name: Some("echo".into()), name: Some("echo".into()),
provider: None,
secrets: Some(vec!["api_key".into()]), secrets: Some(vec!["api_key".into()]),
files: None, files: None,
flag: None, flag: None,
@@ -371,14 +424,131 @@ mod tests {
run_configs: Some(vec![run_cfg]), run_configs: Some(vec![run_cfg]),
..Config::default() ..Config::default()
}; };
let mut dummy = DummyProvider;
let prov: &mut dyn SecretProvider = &mut dummy;
// Capture stderr for dry_run preview
let tokens = vec![OsString::from("echo"), OsString::from("hello")]; let tokens = vec![OsString::from("echo"), OsString::from("hello")];
// Best-effort: ensure function does not error under dry_run let err = wrap_and_run_command(None, &cfg, tokens, None, true)
let res = wrap_and_run_command(prov, &cfg, tokens, None, true).await; .await
assert!(res.is_ok()); .expect_err("expected failed secret resolution in dry_run");
// Not asserting output text to keep test platform-agnostic assert!(err.to_string().contains("Failed to fetch"));
}
#[test]
#[serial]
fn test_run_config_completer_filters_by_prefix() {
let td = tempdir().unwrap();
let xdg = td.path().join("xdg");
unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg) };
let cfg_path = get_config_file_path().unwrap();
let app_dir = cfg_path.parent().unwrap().to_path_buf();
fs::create_dir_all(&app_dir).unwrap();
let yaml = indoc::indoc! {
"---
default_provider: local
providers:
- name: local
type: local
run_configs:
- name: echo
secrets: [API_KEY]
- name: docker
secrets: [DB_PASSWORD]
- name: aws
secrets: [AWS_ACCESS_KEY_ID]
"
};
fs::write(app_dir.join("config.yml"), yaml).unwrap();
let out = run_config_completer(OsStr::new("do"));
assert_eq!(out.len(), 1);
// Compare via debug string to avoid depending on crate internals
let rendered = format!("{:?}", &out[0]);
assert!(rendered.contains("docker"), "got: {}", rendered);
unsafe { std_env::remove_var("XDG_CONFIG_HOME") };
}
#[test]
#[serial]
fn test_provider_completer_lists_matching_providers() {
let td = tempdir().unwrap();
let xdg = td.path().join("xdg");
unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg) };
let cfg_path = get_config_file_path().unwrap();
let app_dir = cfg_path.parent().unwrap().to_path_buf();
fs::create_dir_all(&app_dir).unwrap();
let yaml = indoc::indoc! {
"---
default_provider: local
providers:
- name: local
type: local
- name: prod
type: local
run_configs:
- name: echo
secrets: [API_KEY]
"
};
fs::write(app_dir.join("config.yml"), yaml).unwrap();
// Prefix 'p' should match only 'prod'
let out = provider_completer(OsStr::new("p"));
assert_eq!(out.len(), 1);
let rendered = format!("{:?}", &out[0]);
assert!(rendered.contains("prod"), "got: {}", rendered);
// Empty prefix returns at least both providers
let out_all = provider_completer(OsStr::new(""));
assert!(out_all.len() >= 2);
unsafe { std_env::remove_var("XDG_CONFIG_HOME") };
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn test_secrets_completer_filters_keys_by_prefix() {
let td = tempdir().unwrap();
let xdg = td.path().join("xdg");
unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg) };
let cfg_path = get_config_file_path().unwrap();
let app_dir = cfg_path.parent().unwrap().to_path_buf();
fs::create_dir_all(&app_dir).unwrap();
let yaml = indoc::indoc! {
"---
default_provider: local
providers:
- name: local
type: local
run_configs:
- name: echo
secrets: [API_KEY]
"
};
fs::write(app_dir.join("config.yml"), yaml).unwrap();
// Seed a minimal vault with keys (values are irrelevant for listing)
let vault_yaml = indoc::indoc! {
"---
API_KEY: dummy
DB_PASSWORD: dummy
AWS_ACCESS_KEY_ID: dummy
"
};
fs::write(app_dir.join("vault.yml"), vault_yaml).unwrap();
let out = secrets_completer(OsStr::new("AWS"));
assert_eq!(out.len(), 1);
let rendered = format!("{:?}", &out[0]);
assert!(rendered.contains("AWS_ACCESS_KEY_ID"), "got: {}", rendered);
let out2 = secrets_completer(OsStr::new("DB_"));
assert_eq!(out2.len(), 1);
let rendered2 = format!("{:?}", &out2[0]);
assert!(rendered2.contains("DB_PASSWORD"), "got: {}", rendered2);
unsafe { std_env::remove_var("XDG_CONFIG_HOME") };
} }
} }
+20 -20
View File
@@ -1,8 +1,12 @@
use crate::cli::provider_completer;
use crate::cli::run_config_completer;
use crate::cli::secrets_completer;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Subcommand; use clap::Subcommand;
use clap::{ use clap::{
CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version,
}; };
use clap_complete::{ArgValueCompleter, CompleteEnv};
use crossterm::execute; use crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode}; use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode};
use gman::config::{Config, get_config_file_path, load_config}; use gman::config::{Config, get_config_file_path, load_config};
@@ -48,11 +52,11 @@ struct Cli {
output: Option<OutputFormat>, output: Option<OutputFormat>,
/// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local')) /// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local'))
#[arg(long, value_enum, global = true, env = "GMAN_PROVIDER")] #[arg(long, global = true, env = "GMAN_PROVIDER", add = ArgValueCompleter::new(provider_completer))]
provider: Option<String>, 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, add = ArgValueCompleter::new(run_config_completer))]
profile: Option<String>, profile: Option<String>,
/// Output the command that will be run instead of executing it /// Output the command that will be run instead of executing it
@@ -74,14 +78,17 @@ struct Cli {
#[derive(Subcommand, Clone, Debug)] #[derive(Subcommand, Clone, Debug)]
enum Commands { enum Commands {
/// Add a secret to the configured secret provider /// Add a secret to the configured secret provider
#[clap(aliases = &["set", "create"])]
Add { Add {
/// Name of the secret to store /// Name of the secret to store
name: String, name: String,
}, },
/// Decrypt a secret and print the plaintext /// Decrypt a secret and print the plaintext
#[clap(alias = "show")]
Get { Get {
/// Name of the secret to retrieve /// Name of the secret to retrieve
#[arg(add = ArgValueCompleter::new(secrets_completer))]
name: String, name: String,
}, },
@@ -89,17 +96,21 @@ enum Commands {
/// If a provider does not support updating secrets, this command will return an error. /// If a provider does not support updating secrets, this command will return an error.
Update { Update {
/// Name of the secret to update /// Name of the secret to update
#[arg(add = ArgValueCompleter::new(secrets_completer))]
name: String, name: String,
}, },
/// Delete a secret from the configured secret provider /// Delete a secret from the configured secret provider
#[clap(aliases = &["remove", "rm"])]
Delete { Delete {
/// Name of the secret to delete /// Name of the secret to delete
#[arg(add = ArgValueCompleter::new(secrets_completer))]
name: String, name: String,
}, },
/// List all secrets stored in the configured secret provider (if supported by the provider) /// List all secrets stored in the configured secret provider (if supported by the provider)
/// If a provider does not support listing secrets, this command will return an error. /// If a provider does not support listing secrets, this command will return an error.
#[clap(alias = "ls")]
List {}, List {},
/// Sync secrets with remote storage (if supported by the provider) /// Sync secrets with remote storage (if supported by the provider)
@@ -112,13 +123,6 @@ enum Commands {
/// configured in a corresponding run profile /// configured in a corresponding run profile
#[command(external_subcommand)] #[command(external_subcommand)]
External(Vec<OsString>), External(Vec<OsString>),
/// Generate shell completion scripts
Completions {
/// The shell to generate the script for
#[arg(value_enum)]
shell: clap_complete::Shell,
},
} }
#[tokio::main] #[tokio::main]
@@ -129,6 +133,7 @@ async fn main() -> Result<()> {
panic::set_hook(Box::new(|info| { panic::set_hook(Box::new(|info| {
panic_hook(info); panic_hook(info);
})); }));
CompleteEnv::with_factory(Cli::command).complete();
let cli = Cli::parse(); let cli = Cli::parse();
if cli.show_log_path { if cli.show_log_path {
@@ -145,7 +150,7 @@ async fn main() -> Result<()> {
exit(1); exit(1);
} }
let config = load_config()?; let config = load_config(true)?;
let mut provider_config = config.extract_provider_config(cli.provider.clone())?; let mut provider_config = config.extract_provider_config(cli.provider.clone())?;
let secrets_provider = provider_config.extract_provider(); let secrets_provider = provider_config.extract_provider();
@@ -154,7 +159,7 @@ async fn main() -> Result<()> {
let plaintext = let plaintext =
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?; read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
secrets_provider secrets_provider
.set_secret(&name, plaintext.trim_end()) .set_secret(&name, &plaintext)
.await .await
.map(|_| match cli.output { .map(|_| match cli.output {
Some(_) => (), Some(_) => (),
@@ -185,7 +190,7 @@ async fn main() -> Result<()> {
let plaintext = let plaintext =
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?; read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
secrets_provider secrets_provider
.update_secret(&name, plaintext.trim_end()) .update_secret(&name, &plaintext)
.await .await
.map(|_| match cli.output { .map(|_| match cli.output {
Some(_) => (), Some(_) => (),
@@ -226,7 +231,8 @@ async fn main() -> Result<()> {
} }
} }
Commands::Config {} => { Commands::Config {} => {
let config_yaml = serde_yaml::to_string(&config) let uninterpolated_config = load_config(false)?;
let config_yaml = serde_yaml::to_string(&uninterpolated_config)
.with_context(|| "failed to serialize existing configuration")?; .with_context(|| "failed to serialize existing configuration")?;
let new_config = Editor::new() let new_config = Editor::new()
.edit(&config_yaml) .edit(&config_yaml)
@@ -253,13 +259,7 @@ async fn main() -> Result<()> {
})?; })?;
} }
Commands::External(tokens) => { Commands::External(tokens) => {
wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run) wrap_and_run_command(cli.provider, &config, tokens, cli.profile, cli.dry_run).await?;
.await?;
}
Commands::Completions { shell } => {
let mut cmd = Cli::command();
let bin_name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, bin_name, &mut io::stdout());
} }
} }
+2 -2
View File
@@ -46,7 +46,7 @@ pub fn init_logging_config() -> log4rs::Config {
pub fn get_log_path() -> PathBuf { pub fn get_log_path() -> PathBuf {
let base_dir = dirs::cache_dir().unwrap_or_else(env::temp_dir); let base_dir = dirs::cache_dir().unwrap_or_else(env::temp_dir);
let log_dir = base_dir.join("gman"); let log_dir = base_dir.join(env!("CARGO_CRATE_NAME"));
let dir = if let Err(e) = fs::create_dir_all(&log_dir) { let dir = if let Err(e) = fs::create_dir_all(&log_dir) {
eprintln!( eprintln!(
@@ -77,7 +77,7 @@ pub fn persist_config_file(config: &Config) -> Result<()> {
fs::write(&config_path, s) fs::write(&config_path, s)
.with_context(|| format!("failed to write {}", config_path.display()))?; .with_context(|| format!("failed to write {}", config_path.display()))?;
} else { } else {
confy::store("gman", "config", config) confy::store(env!("CARGO_CRATE_NAME"), "config", config)
.with_context(|| "failed to save updated config via confy")?; .with_context(|| "failed to save updated config via confy")?;
} }
+136 -21
View File
@@ -11,6 +11,7 @@
//! //!
//! let rc = RunConfig{ //! let rc = RunConfig{
//! name: Some("echo".into()), //! name: Some("echo".into()),
//! provider: None,
//! secrets: Some(vec!["api_key".into()]), //! secrets: Some(vec!["api_key".into()]),
//! files: None, //! files: None,
//! flag: None, //! flag: None,
@@ -20,11 +21,13 @@
//! rc.validate().unwrap(); //! rc.validate().unwrap();
//! ``` //! ```
use crate::calling_app_name;
use crate::providers::local::LocalProvider; use crate::providers::local::LocalProvider;
use crate::providers::{SecretProvider, SupportedProvider}; use crate::providers::{SecretProvider, SupportedProvider};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use collections::HashSet; use collections::HashSet;
use log::debug; use log::debug;
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::serde_as; use serde_with::serde_as;
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
@@ -45,6 +48,7 @@ use validator::{Validate, ValidationError};
pub struct RunConfig { pub struct RunConfig {
#[validate(required)] #[validate(required)]
pub name: Option<String>, pub name: Option<String>,
pub provider: Option<String>,
#[validate(required)] #[validate(required)]
pub secrets: Option<Vec<String>>, pub secrets: Option<Vec<String>>,
pub files: Option<Vec<PathBuf>>, pub files: Option<Vec<PathBuf>>,
@@ -161,6 +165,14 @@ impl ProviderConfig {
debug!("Using Azure Key Vault provider"); debug!("Using Azure Key Vault provider");
provider_def provider_def
} }
SupportedProvider::Gopass { provider_def } => {
debug!("Using Gopass provider");
provider_def
}
SupportedProvider::OnePassword { provider_def } => {
debug!("Using 1Password provider");
provider_def
}
} }
} }
} }
@@ -261,48 +273,49 @@ impl Config {
/// Discover the default password file for the local provider. /// Discover the default password file for the local provider.
/// ///
/// On most systems this resolves to `~/.gman_password` when the file /// On most systems this resolves to `~/.<executable_name>_password`
/// exists, otherwise `None`. pub fn local_provider_password_file() -> PathBuf {
pub fn local_provider_password_file() -> Option<PathBuf> { dirs::home_dir()
let candidate = dirs::home_dir().map(|p| p.join(".gman_password")); .map(|p| p.join(format!(".{}_password", calling_app_name())))
match candidate { .expect("unable to determine home directory for local provider password file")
Some(p) if p.exists() => Some(p),
_ => None,
}
} }
} }
/// Load and validate the application configuration. /// Load and validate the application configuration.
/// ///
/// This uses the `confy` crate to load the configuration from a file /// This uses the `confy` crate to load the configuration from a file
/// (e.g. `~/.config/gman/config.yaml`). If the file does /// (e.g. `~/.config/<executable_name>/config.yaml`). If the file does
/// not exist, a default configuration is created and saved. /// not exist, a default configuration is created and saved.
/// ///
/// ```no_run /// ```no_run
/// # use gman::config::load_config; /// # use gman::config::load_config;
/// let config = load_config().unwrap(); /// // Load config with environment variable interpolation enabled
/// let config = load_config(true).unwrap();
/// println!("loaded config: {:?}", config); /// println!("loaded config: {:?}", config);
/// ``` /// ```
pub fn load_config() -> Result<Config> { pub fn load_config(interpolate: bool) -> Result<Config> {
let xdg_path = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from); let xdg_path = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from);
let mut config: Config = if let Some(base) = xdg_path.as_ref() { let mut config: Config = if let Some(base) = xdg_path.as_ref() {
let app_dir = base.join("gman"); let app_dir = base.join(calling_app_name());
let yml = app_dir.join("config.yml"); let yml = app_dir.join("config.yml");
let yaml = app_dir.join("config.yaml"); let yaml = app_dir.join("config.yaml");
if yml.exists() || yaml.exists() { if yml.exists() || yaml.exists() {
let load_path = if yml.exists() { &yml } else { &yaml }; let load_path = if yml.exists() { &yml } else { &yaml };
let content = fs::read_to_string(load_path) let mut content = fs::read_to_string(load_path)
.with_context(|| format!("failed to read config file '{}'", load_path.display()))?; .with_context(|| format!("failed to read config file '{}'", load_path.display()))?;
if interpolate {
content = interpolate_env_vars(&content);
}
let cfg: Config = serde_yaml::from_str(&content).with_context(|| { let cfg: Config = serde_yaml::from_str(&content).with_context(|| {
format!("failed to parse YAML config at '{}'", load_path.display()) format!("failed to parse YAML config at '{}'", load_path.display())
})?; })?;
cfg cfg
} else { } else {
confy::load("gman", "config")? load_confy_config(interpolate)?
} }
} else { } else {
confy::load("gman", "config")? load_confy_config(interpolate)?
}; };
config.validate()?; config.validate()?;
@@ -316,26 +329,128 @@ pub fn load_config() -> Result<Config> {
ref mut provider_def, ref mut provider_def,
} = p.provider_type } = p.provider_type
&& provider_def.password_file.is_none() && provider_def.password_file.is_none()
&& let Some(local_password_file) = Config::local_provider_password_file() && Config::local_provider_password_file().exists()
{ {
provider_def.password_file = Some(local_password_file); provider_def.password_file = Some(Config::local_provider_password_file());
} }
}); });
Ok(config) Ok(config)
} }
/// Returns the configuration file path that `confy` will use for this app. fn load_confy_config(interpolate: bool) -> Result<Config> {
let load_path = confy::get_configuration_file_path(&calling_app_name(), "config")?;
let mut content = fs::read_to_string(&load_path)
.with_context(|| format!("failed to read config file '{}'", load_path.display()))?;
if interpolate {
content = interpolate_env_vars(&content);
}
let cfg: Config = serde_yaml::from_str(&content)
.with_context(|| format!("failed to parse YAML config at '{}'", load_path.display()))?;
Ok(cfg)
}
/// Returns the configuration file path that `confy` will use
pub fn get_config_file_path() -> Result<PathBuf> { pub fn get_config_file_path() -> Result<PathBuf> {
if let Some(base) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) { if let Some(base) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) {
let dir = base.join("gman"); let dir = base.join(calling_app_name());
let yml = dir.join("config.yml"); let yml = dir.join("config.yml");
let yaml = dir.join("config.yaml"); let yaml = dir.join("config.yaml");
if yml.exists() || yaml.exists() { if yml.exists() || yaml.exists() {
return Ok(if yml.exists() { yml } else { yaml }); return Ok(if yml.exists() { yml } else { yaml });
} }
// Prefer .yml if creating anew
return Ok(dir.join("config.yml")); return Ok(dir.join("config.yml"));
} }
Ok(confy::get_configuration_file_path("gman", "config")?) Ok(confy::get_configuration_file_path(
&calling_app_name(),
"config",
)?)
}
pub fn interpolate_env_vars(s: &str) -> String {
let result = s.to_string();
let scrubbing_regex = Regex::new(r#"[\s{}^()\[\]\\|`'"]+"#).unwrap();
let var_regex = Regex::new(r"\$\{(.*?)(:-.+)?}").unwrap();
var_regex
.replace_all(s, |caps: &regex::Captures<'_>| {
if let Some(mat) = caps.get(1) {
if let Ok(value) = env::var(mat.as_str()) {
return scrubbing_regex.replace_all(&value, "").to_string();
} else if let Some(default_value) = caps.get(2) {
return scrubbing_regex
.replace_all(
default_value
.as_str()
.strip_prefix(":-")
.expect("unable to strip ':-' prefix from default value"),
"",
)
.to_string();
}
}
scrubbing_regex.replace_all(&result, "").to_string()
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_str_eq;
use serial_test::serial;
#[test]
fn test_interpolate_env_vars_defaults_to_original_string_if_not_in_yaml_interpolation_format() {
let var = interpolate_env_vars("TEST_VAR_INTERPOLATION_NON_YAML");
assert_str_eq!(var, "TEST_VAR_INTERPOLATION_NON_YAML");
}
#[test]
#[serial]
fn test_interpolate_env_vars_scrubs_all_unnecessary_characters() {
unsafe {
env::set_var(
"TEST_VAR_INTERPOLATION_UNNECESSARY_CHARACTERS",
r#"""
`"'https://dontdo:this@testing.com/query?test=%20query#results'"` {([\|])}
"""#,
)
};
let var = interpolate_env_vars("${TEST_VAR_INTERPOLATION_UNNECESSARY_CHARACTERS}");
assert_str_eq!(
var,
"https://dontdo:this@testing.com/query?test=%20query#results"
);
unsafe { env::remove_var("TEST_VAR_INTERPOLATION_UNNECESSARY_CHARACTERS") };
}
#[test]
#[serial]
fn test_interpolate_env_vars_scrubs_all_unnecessary_characters_for_default_values() {
let var = interpolate_env_vars(
r#"${UNSET:-`"'https://dontdo:this@testing.com/query?test=%20query#results'"` {([\|])}}"#,
);
assert_str_eq!(
var,
"https://dontdo:this@testing.com/query?test=%20query#results"
);
}
#[test]
fn test_interpolate_env_vars_scrubs_all_unnecessary_characters_from_non_environment_variable() {
let var =
interpolate_env_vars("https://dontdo:this@testing.com/query?test=%20query#results");
assert_str_eq!(
var,
"https://dontdo:this@testing.com/query?test=%20query#results"
);
}
} }
+79 -35
View File
@@ -20,29 +20,30 @@
//! The `config` and `providers` modules power the CLI. They can be embedded //! The `config` and `providers` modules power the CLI. They can be embedded
//! in other programs, but many functions interact with the user or the //! in other programs, but many functions interact with the user or the
//! filesystem. Prefer `no_run` doctests for those. //! filesystem. Prefer `no_run` doctests for those.
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use argon2::{ use argon2::{Algorithm, Argon2, Params, Version, password_hash::rand_core::RngCore};
Algorithm, Argon2, Params, Version,
password_hash::{SaltString, rand_core::RngCore},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
use chacha20poly1305::{ use chacha20poly1305::{
Key, XChaCha20Poly1305, XNonce, Key, XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, OsRng}, aead::{Aead, KeyInit, OsRng},
}; };
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use std::path::PathBuf;
use zeroize::Zeroize; use zeroize::Zeroize;
/// Configuration structures and helpers used by the CLI and library. /// Configuration structures and helpers used by the CLI and library.
pub mod config; pub mod config;
/// Secret provider trait and implementations. /// Secret provider trait and implementations.
pub mod providers; pub mod providers;
pub use providers::{SecretError, SyncError};
pub(crate) const HEADER: &str = "$VAULT"; pub(crate) const HEADER: &str = "$VAULT";
pub(crate) const VERSION: &str = "v1"; pub(crate) const VERSION: &str = "v1";
pub(crate) const KDF: &str = "argon2id"; pub(crate) const KDF: &str = "argon2id";
pub(crate) const ARGON_M_COST_KIB: u32 = 19_456; pub(crate) const ARGON_M_COST_KIB: u32 = 65_536;
pub(crate) const ARGON_T_COST: u32 = 2; pub(crate) const ARGON_T_COST: u32 = 3;
pub(crate) const ARGON_P: u32 = 1; pub(crate) const ARGON_P: u32 = 1;
pub(crate) const SALT_LEN: usize = 16; pub(crate) const SALT_LEN: usize = 16;
@@ -59,7 +60,7 @@ 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 key = *Key::from_slice(&key_bytes); let key: Key = key_bytes.into();
key_bytes.zeroize(); key_bytes.zeroize();
Ok(key) Ok(key)
} }
@@ -82,20 +83,28 @@ fn derive_key(password: &SecretString, salt: &[u8]) -> Result<Key> {
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();
let salt = SaltString::generate(&mut OsRng); if password.expose_secret().is_empty() {
bail!("password cannot be empty");
}
let mut salt = [0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt);
let mut nonce_bytes = [0u8; NONCE_LEN]; let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes); OsRng.fill_bytes(&mut nonce_bytes);
let key = derive_key(&password, salt.as_str().as_bytes())?; let mut key = derive_key(&password, &salt)?;
let cipher = XChaCha20Poly1305::new(&key); let cipher = XChaCha20Poly1305::new(&key);
let aad = format!("{};{}", HEADER, VERSION); let aad = format!(
"{};{};{};m={},t={},p={}",
HEADER, VERSION, KDF, ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P
);
let nonce = XNonce::from_slice(&nonce_bytes); let nonce: XNonce = nonce_bytes.into();
let mut pt = plaintext.as_bytes().to_vec(); let mut pt = plaintext.as_bytes().to_vec();
let ct = cipher let ct = cipher
.encrypt( .encrypt(
nonce, &nonce,
chacha20poly1305::aead::Payload { chacha20poly1305::aead::Payload {
msg: &pt, msg: &pt,
aad: aad.as_bytes(), aad: aad.as_bytes(),
@@ -113,13 +122,14 @@ pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Res
m = ARGON_M_COST_KIB, m = ARGON_M_COST_KIB,
t = ARGON_T_COST, t = ARGON_T_COST,
p = ARGON_P, p = ARGON_P,
salt = B64.encode(salt.as_str().as_bytes()), salt = B64.encode(salt),
nonce = B64.encode(nonce_bytes), nonce = B64.encode(nonce_bytes),
ct = B64.encode(&ct), ct = B64.encode(&ct),
); );
drop(cipher); drop(cipher);
let _ = key; key.zeroize();
salt.zeroize();
nonce_bytes.zeroize(); nonce_bytes.zeroize();
Ok(env) Ok(env)
@@ -130,6 +140,9 @@ pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Res
/// Returns the original plaintext on success or an error if the password is /// Returns the original plaintext on success or an error if the password is
/// wrong, the envelope was tampered with, or the input is malformed. /// wrong, the envelope was tampered with, or the input is malformed.
/// ///
/// This function supports both the current format (with KDF params in AAD) and
/// the legacy format (without KDF params in AAD) for backwards compatibility.
///
/// Example /// Example
/// ``` /// ```
/// use gman::{encrypt_string, decrypt_string}; /// use gman::{encrypt_string, decrypt_string};
@@ -143,6 +156,10 @@ pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Res
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();
if password.expose_secret().is_empty() {
bail!("password cannot be empty");
}
let parts: Vec<&str> = envelope.split(';').collect(); let parts: Vec<&str> = envelope.split(';').collect();
if parts.len() < 7 { if parts.len() < 7 {
bail!("invalid envelope format"); bail!("invalid envelope format");
@@ -176,37 +193,66 @@ pub fn decrypt_string(password: impl Into<SecretString>, envelope: &str) -> Resu
let nonce_b64 = parts[5].strip_prefix("nonce=").context("missing nonce")?; let nonce_b64 = parts[5].strip_prefix("nonce=").context("missing nonce")?;
let ct_b64 = parts[6].strip_prefix("ct=").context("missing ct")?; let ct_b64 = parts[6].strip_prefix("ct=").context("missing ct")?;
let salt_bytes = B64.decode(salt_b64).context("bad salt b64")?; let mut salt_bytes = B64.decode(salt_b64).context("bad salt b64")?;
let mut nonce_bytes = B64.decode(nonce_b64).context("bad nonce b64")?; let nonce_bytes = B64.decode(nonce_b64).context("bad nonce b64")?;
let mut ct = B64.decode(ct_b64).context("bad ct b64")?; let mut ct = B64.decode(ct_b64).context("bad ct b64")?;
if nonce_bytes.len() != NONCE_LEN { if nonce_bytes.len() != NONCE_LEN {
bail!("nonce length mismatch"); bail!("nonce length mismatch");
} }
let key = derive_key(&password, &salt_bytes)?; let mut key = derive_key(&password, &salt_bytes)?;
let cipher = XChaCha20Poly1305::new(&key); let cipher = XChaCha20Poly1305::new(&key);
let aad = format!("{};{}", HEADER, VERSION); let aad_new = format!("{};{};{};m={},t={},p={}", HEADER, VERSION, KDF, m, t, p);
let nonce = XNonce::from_slice(&nonce_bytes); let aad_legacy = format!("{};{}", HEADER, VERSION);
let pt = cipher
.decrypt(
nonce,
chacha20poly1305::aead::Payload {
msg: &ct,
aad: aad.as_bytes(),
},
)
.map_err(|_| anyhow!("decryption failed (wrong password or corrupted data)"))?;
nonce_bytes.zeroize(); let mut nonce_arr: [u8; NONCE_LEN] = nonce_bytes
.try_into()
.map_err(|_| anyhow!("invalid nonce length"))?;
let nonce: XNonce = nonce_arr.into();
let decrypt_result = cipher.decrypt(
&nonce,
chacha20poly1305::aead::Payload {
msg: &ct,
aad: aad_new.as_bytes(),
},
);
let mut pt = match decrypt_result {
Ok(pt) => pt,
Err(_) => cipher
.decrypt(
&nonce,
chacha20poly1305::aead::Payload {
msg: &ct,
aad: aad_legacy.as_bytes(),
},
)
.map_err(|_| anyhow!("decryption failed (wrong password or corrupted data)"))?,
};
let s = String::from_utf8(pt.clone()).context("plaintext not valid UTF-8")?;
key.zeroize();
salt_bytes.zeroize();
nonce_arr.zeroize();
ct.zeroize(); ct.zeroize();
pt.zeroize();
let s = String::from_utf8(pt).context("plaintext not valid UTF-8")?;
Ok(s) Ok(s)
} }
pub(crate) fn calling_app_name() -> String {
let exe: PathBuf = std::env::current_exe().expect("unable to get current exe path");
exe.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_owned())
.expect("executable name not valid UTF-8")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -237,12 +283,10 @@ mod tests {
} }
#[test] #[test]
fn empty_password() { fn empty_password_rejected() {
let pw = SecretString::new("".into()); let pw = SecretString::new("".into());
let msg = "hello"; let msg = "hello";
let env = encrypt_string(pw.clone(), msg).unwrap(); assert!(encrypt_string(pw.clone(), msg).is_err());
let out = decrypt_string(pw, &env).unwrap();
assert_eq!(msg, out);
} }
#[test] #[test]
@@ -264,7 +308,7 @@ mod tests {
let mut ct = base64::engine::general_purpose::STANDARD let mut ct = base64::engine::general_purpose::STANDARD
.decode(ct_b64) .decode(ct_b64)
.unwrap(); .unwrap();
ct[0] ^= 0x01; // Flip a bit ct[0] ^= 0x01;
let new_ct_b64 = base64::engine::general_purpose::STANDARD.encode(&ct); let new_ct_b64 = base64::engine::general_purpose::STANDARD.encode(&ct);
let new_ct_part = format!("ct={}", new_ct_b64); let new_ct_part = format!("ct={}", new_ct_b64);
parts[6] = &new_ct_part; parts[6] = &new_ct_part;
+39 -29
View File
@@ -1,12 +1,14 @@
use crate::providers::SecretProvider;
use anyhow::Context;
use anyhow::Result;
use aws_config::Region; use aws_config::Region;
use aws_sdk_secretsmanager::Client; use aws_sdk_secretsmanager::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
use validator::Validate; use validator::Validate;
use crate::providers::error::{SecretError, classify_aws_error};
use crate::providers::SecretProvider;
const PROVIDER: &str = "aws_secrets_manager";
#[skip_serializing_none] #[skip_serializing_none]
/// Configuration for AWS Secrets Manager provider /// Configuration for AWS Secrets Manager provider
/// See [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) /// See [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/)
@@ -43,18 +45,21 @@ impl SecretProvider for AwsSecretsManagerProvider {
"AwsSecretsManagerProvider" "AwsSecretsManagerProvider"
} }
async fn get_secret(&self, key: &str) -> Result<String> { async fn get_secret(&self, key: &str) -> Result<String, SecretError> {
self.get_client() let client = self.get_client().await?;
.await? let resp = client
.get_secret_value() .get_secret_value()
.secret_id(key) .secret_id(key)
.send() .send()
.await? .await
.secret_string .map_err(|e| classify_aws_error(e.into(), Some(key), "get_secret"))?;
.with_context(|| format!("Secret '{key}' not found")) resp.secret_string.ok_or_else(|| SecretError::NotFound {
key: key.to_string(),
provider: PROVIDER,
})
} }
async fn set_secret(&self, key: &str, value: &str) -> Result<()> { async fn set_secret(&self, key: &str, value: &str) -> Result<(), SecretError> {
self.get_client() self.get_client()
.await? .await?
.create_secret() .create_secret()
@@ -62,12 +67,12 @@ impl SecretProvider for AwsSecretsManagerProvider {
.secret_string(value) .secret_string(value)
.send() .send()
.await .await
.with_context(|| format!("Failed to set secret '{key}'"))?; .map_err(|e| classify_aws_error(e.into(), Some(key), "set_secret"))?;
Ok(()) Ok(())
} }
async fn update_secret(&self, key: &str, value: &str) -> Result<()> { async fn update_secret(&self, key: &str, value: &str) -> Result<(), SecretError> {
self.get_client() self.get_client()
.await? .await?
.update_secret() .update_secret()
@@ -75,12 +80,12 @@ impl SecretProvider for AwsSecretsManagerProvider {
.secret_string(value) .secret_string(value)
.send() .send()
.await .await
.with_context(|| format!("Failed to update secret '{key}'"))?; .map_err(|e| classify_aws_error(e.into(), Some(key), "update_secret"))?;
Ok(()) Ok(())
} }
async fn delete_secret(&self, key: &str) -> Result<()> { async fn delete_secret(&self, key: &str) -> Result<(), SecretError> {
self.get_client() self.get_client()
.await? .await?
.delete_secret() .delete_secret()
@@ -88,32 +93,37 @@ impl SecretProvider for AwsSecretsManagerProvider {
.force_delete_without_recovery(true) .force_delete_without_recovery(true)
.send() .send()
.await .await
.with_context(|| format!("Failed to delete secret '{key}'"))?; .map_err(|e| classify_aws_error(e.into(), Some(key), "delete_secret"))?;
Ok(()) Ok(())
} }
async fn list_secrets(&self) -> Result<Vec<String>> { async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
self.get_client() let resp = self
.get_client()
.await? .await?
.list_secrets() .list_secrets()
.send() .send()
.await? .await
.map_err(|e| classify_aws_error(e.into(), None, "list_secrets"))?;
Ok(resp
.secret_list .secret_list
.with_context(|| "No secrets found") .unwrap_or_default()
.map(|secrets| secrets.into_iter().filter_map(|s| s.name).collect()) .into_iter()
.filter_map(|s| s.name)
.collect())
} }
} }
impl AwsSecretsManagerProvider { impl AwsSecretsManagerProvider {
async fn get_client(&self) -> Result<Client> { async fn get_client(&self) -> Result<Client, SecretError> {
let region = self let region = self.aws_region.clone().ok_or_else(|| SecretError::Config {
.aws_region provider: PROVIDER,
.clone() message: "aws_region is required".to_string(),
.with_context(|| "aws_region is required")?; })?;
let profile = self let profile = self.aws_profile.clone().ok_or_else(|| SecretError::Config {
.aws_profile provider: PROVIDER,
.clone() message: "aws_profile is required".to_string(),
.with_context(|| "aws_profile is required")?; })?;
let config = aws_config::from_env() let config = aws_config::from_env()
.region(Region::new(region)) .region(Region::new(region))
+65 -32
View File
@@ -1,6 +1,7 @@
use crate::providers::SecretProvider; use std::sync::Arc;
use anyhow::{Context, Result};
use azure_identity::DefaultAzureCredential; use azure_core::credentials::TokenCredential;
use azure_identity::DeveloperToolsCredential;
use azure_security_keyvault_secrets::models::SetSecretParameters; use azure_security_keyvault_secrets::models::SetSecretParameters;
use azure_security_keyvault_secrets::{ResourceExt, SecretClient}; use azure_security_keyvault_secrets::{ResourceExt, SecretClient};
use futures::TryStreamExt; use futures::TryStreamExt;
@@ -8,6 +9,11 @@ use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
use validator::Validate; use validator::Validate;
use crate::providers::SecretProvider;
use crate::providers::error::{SecretError, classify_azure_error};
const PROVIDER: &str = "azure_key_vault";
#[skip_serializing_none] #[skip_serializing_none]
/// Configuration for Azure Key Vault provider /// Configuration for Azure Key Vault provider
/// See [Azure Key Vault](https://azure.microsoft.com/en-us/services/key-vault/) /// See [Azure Key Vault](https://azure.microsoft.com/en-us/services/key-vault/)
@@ -39,51 +45,70 @@ impl SecretProvider for AzureKeyVaultProvider {
"AzureKeyVaultProvider" "AzureKeyVaultProvider"
} }
async fn get_secret(&self, key: &str) -> Result<String> { async fn get_secret(&self, key: &str) -> Result<String, SecretError> {
let body = self let response = self
.get_client()? .get_client()?
.get_secret(key, "", None) .get_secret(key, None)
.await? .await
.into_body() .map_err(|e| classify_azure_error(e.into(), Some(key), "get_secret"))?;
.await?; let body = response
.into_model()
.map_err(|e| SecretError::Other(e.into()))?;
body.value body.value.ok_or_else(|| SecretError::NotFound {
.with_context(|| format!("Secret '{}' not found", key)) key: key.to_string(),
provider: PROVIDER,
})
} }
async fn set_secret(&self, key: &str, value: &str) -> Result<()> { async fn set_secret(&self, key: &str, value: &str) -> Result<(), SecretError> {
let params = SetSecretParameters { let params = SetSecretParameters {
value: Some(value.to_string()), value: Some(value.to_string()),
..Default::default() ..Default::default()
}; };
let body = params
.try_into()
.map_err(|e: azure_core::Error| classify_azure_error(e.into(), Some(key), "set_secret"))?;
self.get_client()? self.get_client()?
.set_secret(key, params.try_into()?, None) .set_secret(key, body, None)
.await? .await
.into_body() .map_err(|e| classify_azure_error(e.into(), Some(key), "set_secret"))?
.await?; .into_model()
.map_err(|e| SecretError::Other(e.into()))?;
Ok(()) Ok(())
} }
async fn update_secret(&self, key: &str, value: &str) -> Result<()> { async fn update_secret(&self, key: &str, value: &str) -> Result<(), SecretError> {
self.set_secret(key, value).await self.set_secret(key, value).await
} }
async fn delete_secret(&self, key: &str) -> Result<()> { async fn delete_secret(&self, key: &str) -> Result<(), SecretError> {
self.get_client()?.delete_secret(key, None).await?; self.get_client()?
.delete_secret(key, None)
.await
.map_err(|e| classify_azure_error(e.into(), Some(key), "delete_secret"))?;
Ok(()) Ok(())
} }
async fn list_secrets(&self) -> Result<Vec<String>> { async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
let mut pager = self let mut pager = self
.get_client()? .get_client()?
.list_secret_properties(None)? .list_secret_properties(None)
.into_stream(); .map_err(|e| classify_azure_error(e.into(), None, "list_secrets"))?;
let mut secrets = Vec::new(); let mut secrets = Vec::new();
while let Some(props) = pager.try_next().await? { while let Some(props) = pager
let name = props.resource_id()?.name; .try_next()
.await
.map_err(|e| classify_azure_error(e.into(), None, "list_secrets"))?
{
let name = props
.resource_id()
.map_err(|e| SecretError::Other(e.into()))?
.name;
secrets.push(name); secrets.push(name);
} }
@@ -92,17 +117,25 @@ impl SecretProvider for AzureKeyVaultProvider {
} }
impl AzureKeyVaultProvider { impl AzureKeyVaultProvider {
fn get_client(&self) -> Result<SecretClient> { fn get_client(&self) -> Result<SecretClient, SecretError> {
let credential = DefaultAzureCredential::new()?; let credential: Arc<dyn TokenCredential> =
DeveloperToolsCredential::new(None).map_err(|e| SecretError::AuthFailed {
provider: PROVIDER,
source: e.into(),
})?;
let vault_name = self.vault_name.as_ref().ok_or_else(|| SecretError::Config {
provider: PROVIDER,
message: "vault_name is required".to_string(),
})?;
let client = SecretClient::new( let client = SecretClient::new(
format!( format!("https://{}.vault.azure.net", vault_name).as_str(),
"https://{}.vault.azure.net",
self.vault_name.as_ref().unwrap()
)
.as_str(),
credential, credential,
None, None,
)?; )
.map_err(|e| SecretError::Config {
provider: PROVIDER,
message: format!("failed to create Azure Key Vault client: {}", e),
})?;
Ok(client) Ok(client)
} }
+225
View File
@@ -0,0 +1,225 @@
use std::io;
use anyhow::anyhow;
use thiserror::Error;
use crate::providers::git_sync::SyncError;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SecretError {
#[error("secret '{key}' not found in provider '{provider}'")]
NotFound { key: String, provider: &'static str },
#[error(
"secret '{key}' already exists in provider '{provider}' (use update_secret to change its value)"
)]
AlreadyExists { key: String, provider: &'static str },
#[error("authentication failed for provider '{provider}': {source}")]
AuthFailed {
provider: &'static str,
#[source]
source: anyhow::Error,
},
#[error("network error contacting provider '{provider}': {source}")]
Network {
provider: &'static str,
#[source]
source: anyhow::Error,
},
#[error("operation '{operation}' not supported by provider '{provider}'")]
Unsupported {
operation: &'static str,
provider: &'static str,
},
#[error("required CLI tool '{tool}' not found in PATH")]
CliNotFound { tool: &'static str },
#[error("provider '{provider}' configuration error: {message}")]
Config {
provider: &'static str,
message: String,
},
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl From<SyncError> for SecretError {
fn from(err: SyncError) -> Self {
match err {
SyncError::GitNotFound => SecretError::CliNotFound { tool: "git" },
SyncError::AuthFailed { source } => SecretError::AuthFailed {
provider: "local",
source,
},
SyncError::Network { source } => SecretError::Network {
provider: "local",
source,
},
SyncError::Config { message } => SecretError::Config {
provider: "local",
message,
},
SyncError::GitCommandFailed { message } => {
SecretError::Other(anyhow!("git command failed: {}", message))
}
SyncError::Io(e) => SecretError::Io(e),
SyncError::Other(e) => SecretError::Other(e),
}
}
}
pub(crate) fn classify_aws_error(
err: anyhow::Error,
key: Option<&str>,
_op: &'static str,
) -> SecretError {
let provider = "aws_secrets_manager";
let chain_text = err
.chain()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" | ")
.to_lowercase();
if chain_text.contains("resourcenotfoundexception") || chain_text.contains("not found") {
SecretError::NotFound {
key: key.unwrap_or("").to_string(),
provider,
}
} else if chain_text.contains("alreadyexistsexception") {
SecretError::AlreadyExists {
key: key.unwrap_or("").to_string(),
provider,
}
} else if chain_text.contains("accessdenied")
|| chain_text.contains("expiredtoken")
|| chain_text.contains("invalidsignature")
|| chain_text.contains("unauthorized")
|| chain_text.contains("unrecognizedclient")
{
SecretError::AuthFailed { provider, source: err }
} else if chain_text.contains("dispatch failure")
|| chain_text.contains("timeout")
|| chain_text.contains("connection")
|| chain_text.contains("dns")
{
SecretError::Network { provider, source: err }
} else {
SecretError::Other(err)
}
}
pub(crate) fn classify_gcp_error(
err: anyhow::Error,
key: Option<&str>,
_op: &'static str,
) -> SecretError {
let provider = "gcp_secret_manager";
if let Some(status) = err.downcast_ref::<gcloud_sdk::tonic::Status>() {
use gcloud_sdk::tonic::Code;
return match status.code() {
Code::NotFound => SecretError::NotFound {
key: key.unwrap_or("").to_string(),
provider,
},
Code::AlreadyExists => SecretError::AlreadyExists {
key: key.unwrap_or("").to_string(),
provider,
},
Code::Unauthenticated | Code::PermissionDenied => SecretError::AuthFailed {
provider,
source: err,
},
Code::Unavailable | Code::DeadlineExceeded => SecretError::Network {
provider,
source: err,
},
_ => SecretError::Other(err),
};
}
let chain_text = err
.chain()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" | ")
.to_lowercase();
if chain_text.contains("notfound") || chain_text.contains("not found") {
SecretError::NotFound {
key: key.unwrap_or("").to_string(),
provider,
}
} else if chain_text.contains("alreadyexists") || chain_text.contains("already exists") {
SecretError::AlreadyExists {
key: key.unwrap_or("").to_string(),
provider,
}
} else if chain_text.contains("unauthenticated") || chain_text.contains("permissiondenied") {
SecretError::AuthFailed { provider, source: err }
} else if chain_text.contains("unavailable") || chain_text.contains("deadlineexceeded") {
SecretError::Network { provider, source: err }
} else {
SecretError::Other(err)
}
}
pub(crate) fn classify_azure_error(
err: anyhow::Error,
key: Option<&str>,
_op: &'static str,
) -> SecretError {
let provider = "azure_key_vault";
if let Some(azure_err) = err.downcast_ref::<azure_core::Error>() {
use azure_core::error::ErrorKind;
if let ErrorKind::HttpResponse { status, .. } = azure_err.kind() {
let code = u16::from(*status);
return match code {
401 | 403 => SecretError::AuthFailed { provider, source: err },
404 => SecretError::NotFound {
key: key.unwrap_or("").to_string(),
provider,
},
_ => SecretError::Other(err),
};
}
}
let chain_text = err
.chain()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(" | ")
.to_lowercase();
if chain_text.contains("not found") || chain_text.contains("notfound") {
SecretError::NotFound {
key: key.unwrap_or("").to_string(),
provider,
}
} else if chain_text.contains("unauthorized")
|| chain_text.contains("forbidden")
|| chain_text.contains("401")
|| chain_text.contains("403")
|| chain_text.contains("authentication")
{
SecretError::AuthFailed { provider, source: err }
} else if chain_text.contains("timeout")
|| chain_text.contains("connection")
|| chain_text.contains("dns")
{
SecretError::Network { provider, source: err }
} else {
SecretError::Other(err)
}
}
+42 -25
View File
@@ -1,5 +1,4 @@
use crate::providers::SecretProvider; use anyhow::anyhow;
use anyhow::{Context, Result, anyhow};
use gcloud_sdk::google::cloud::secretmanager::v1; use gcloud_sdk::google::cloud::secretmanager::v1;
use gcloud_sdk::google::cloud::secretmanager::v1::replication::Automatic; use gcloud_sdk::google::cloud::secretmanager::v1::replication::Automatic;
use gcloud_sdk::google::cloud::secretmanager::v1::secret_manager_service_client::SecretManagerServiceClient; use gcloud_sdk::google::cloud::secretmanager::v1::secret_manager_service_client::SecretManagerServiceClient;
@@ -15,8 +14,13 @@ use serde_with::skip_serializing_none;
use v1::DeleteSecretRequest; use v1::DeleteSecretRequest;
use validator::Validate; use validator::Validate;
use crate::providers::SecretProvider;
use crate::providers::error::{SecretError, classify_gcp_error};
type SecretsManagerClient = GoogleApi<SecretManagerServiceClient<GoogleAuthMiddleware>>; type SecretsManagerClient = GoogleApi<SecretManagerServiceClient<GoogleAuthMiddleware>>;
const PROVIDER: &str = "gcp_secret_manager";
#[skip_serializing_none] #[skip_serializing_none]
/// Configuration for GCP Secret Manager provider /// Configuration for GCP Secret Manager provider
/// See [GCP Secret Manager](https://cloud.google.com/secret-manager) /// See [GCP Secret Manager](https://cloud.google.com/secret-manager)
@@ -48,8 +52,8 @@ impl SecretProvider for GcpSecretManagerProvider {
"GcpSecretManagerProvider" "GcpSecretManagerProvider"
} }
async fn get_secret(&self, key: &str) -> Result<String> { async fn get_secret(&self, key: &str) -> Result<String, SecretError> {
let secret_value = self let response = self
.get_client() .get_client()
.await? .await?
.get() .get()
@@ -60,20 +64,22 @@ impl SecretProvider for GcpSecretManagerProvider {
key key
), ),
}) })
.await? .await
.into_inner() .map_err(|e| classify_gcp_error(e.into(), Some(key), "get_secret"))?
.payload .into_inner();
.ok_or_else(|| anyhow!("Secret '{}' not found", key))? let payload = response.payload.ok_or_else(|| SecretError::NotFound {
.data key: key.to_string(),
.ref_sensitive_value() provider: PROVIDER,
.to_vec(); })?;
let secret_string = String::from_utf8(secret_value) let secret_value = payload.data.ref_sensitive_value().to_vec();
.with_context(|| format!("Invalid UTF-8 in secret '{})'", key))?; let secret_string = String::from_utf8(secret_value).map_err(|_| {
SecretError::Other(anyhow!("secret value is not valid UTF-8"))
})?;
Ok(secret_string) Ok(secret_string)
} }
async fn set_secret(&self, key: &str, value: &str) -> Result<()> { async fn set_secret(&self, key: &str, value: &str) -> Result<(), SecretError> {
let parent = format!("projects/{}", self.gcp_project_id.as_ref().unwrap()); let parent = format!("projects/{}", self.gcp_project_id.as_ref().unwrap());
let secret_name = format!("{}/secrets/{}", parent, key); let secret_name = format!("{}/secrets/{}", parent, key);
let secret = Secret { let secret = Secret {
@@ -96,9 +102,12 @@ impl SecretProvider for GcpSecretManagerProvider {
.await .await
.map_err(|e| { .map_err(|e| {
if e.code() == Code::AlreadyExists { if e.code() == Code::AlreadyExists {
anyhow!("Secret already exists") SecretError::AlreadyExists {
key: key.to_string(),
provider: PROVIDER,
}
} else { } else {
e.into() classify_gcp_error(e.into(), Some(key), "set_secret")
} }
})?; })?;
@@ -113,12 +122,13 @@ impl SecretProvider for GcpSecretManagerProvider {
data_crc32c: Some(crc32c), data_crc32c: Some(crc32c),
}), }),
}) })
.await?; .await
.map_err(|e| classify_gcp_error(e.into(), Some(key), "set_secret"))?;
Ok(()) Ok(())
} }
async fn delete_secret(&self, key: &str) -> Result<()> { async fn delete_secret(&self, key: &str) -> Result<(), SecretError> {
let name = format!( let name = format!(
"projects/{}/secrets/{}", "projects/{}/secrets/{}",
self.gcp_project_id.as_ref().unwrap(), self.gcp_project_id.as_ref().unwrap(),
@@ -131,11 +141,12 @@ impl SecretProvider for GcpSecretManagerProvider {
name, name,
etag: "".to_string(), etag: "".to_string(),
}) })
.await?; .await
.map_err(|e| classify_gcp_error(e.into(), Some(key), "delete_secret"))?;
Ok(()) Ok(())
} }
async fn update_secret(&self, key: &str, value: &str) -> Result<()> { async fn update_secret(&self, key: &str, value: &str) -> Result<(), SecretError> {
let parent = format!( let parent = format!(
"projects/{}/secrets/{}", "projects/{}/secrets/{}",
self.gcp_project_id.as_ref().unwrap(), self.gcp_project_id.as_ref().unwrap(),
@@ -154,12 +165,13 @@ impl SecretProvider for GcpSecretManagerProvider {
data_crc32c: Some(crc32c), data_crc32c: Some(crc32c),
}), }),
}) })
.await?; .await
.map_err(|e| classify_gcp_error(e.into(), Some(key), "update_secret"))?;
Ok(()) Ok(())
} }
async fn list_secrets(&self) -> Result<Vec<String>> { async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
let request = ListSecretsRequest { let request = ListSecretsRequest {
parent: format!("projects/{}", self.gcp_project_id.as_ref().unwrap()), parent: format!("projects/{}", self.gcp_project_id.as_ref().unwrap()),
..Default::default() ..Default::default()
@@ -169,7 +181,8 @@ impl SecretProvider for GcpSecretManagerProvider {
.await? .await?
.get() .get()
.list_secrets(request) .list_secrets(request)
.await? .await
.map_err(|e| classify_gcp_error(e.into(), None, "list_secrets"))?
.into_inner() .into_inner()
.secrets .secrets
.iter() .iter()
@@ -188,13 +201,17 @@ impl SecretProvider for GcpSecretManagerProvider {
} }
impl GcpSecretManagerProvider { impl GcpSecretManagerProvider {
async fn get_client(&self) -> Result<SecretsManagerClient> { async fn get_client(&self) -> Result<SecretsManagerClient, SecretError> {
let client = GoogleApi::from_function( let client = GoogleApi::from_function(
SecretManagerServiceClient::new, SecretManagerServiceClient::new,
"https://secretmanager.googleapis.com", "https://secretmanager.googleapis.com",
None, None,
) )
.await?; .await
.map_err(|e| SecretError::AuthFailed {
provider: PROVIDER,
source: e.into(),
})?;
Ok(client) Ok(client)
} }
+189 -101
View File
@@ -1,14 +1,51 @@
use anyhow::{Context, Result, anyhow}; use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::{env, fs};
use anyhow::anyhow;
use chrono::Utc; use chrono::Utc;
use dialoguer::Confirm; use dialoguer::Confirm;
use dialoguer::theme::ColorfulTheme; use dialoguer::theme::ColorfulTheme;
use indoc::formatdoc; use indoc::formatdoc;
use log::debug; use log::debug;
use std::path::{Path, PathBuf}; use thiserror::Error;
use std::process::{Command, Stdio};
use std::{env, fs};
use validator::Validate; use validator::Validate;
use crate::calling_app_name;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SyncError {
#[error("git binary not found in PATH")]
GitNotFound,
#[error("git authentication failed (check SSH key / token): {source}")]
AuthFailed {
#[source]
source: anyhow::Error,
},
#[error("network error during git sync: {source}")]
Network {
#[source]
source: anyhow::Error,
},
#[error("git configuration error: {message}")]
Config { message: String },
#[error("git command failed: {message}")]
GitCommandFailed { message: String },
#[error("I/O error during sync: {0}")]
Io(#[from] io::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
type SyncResult<T> = std::result::Result<T, SyncError>;
#[derive(Debug, Validate, Clone)] #[derive(Debug, Validate, Clone)]
pub struct SyncOpts<'a> { pub struct SyncOpts<'a> {
#[validate(required)] #[validate(required)]
@@ -20,38 +57,36 @@ pub struct SyncOpts<'a> {
pub git_executable: &'a Option<PathBuf>, pub git_executable: &'a Option<PathBuf>,
} }
pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> { pub fn sync_and_push(opts: &SyncOpts<'_>) -> SyncResult<()> {
debug!("Syncing with git: {:?}", opts); debug!("Syncing with git: {:?}", opts);
opts.validate() opts.validate().map_err(|e| SyncError::Config {
.with_context(|| "invalid git sync options")?; message: format!("invalid git sync options: {}", e),
})?;
let commit_message = format!("chore: sync @ {}", Utc::now().to_rfc3339()); let commit_message = format!("chore: sync @ {}", Utc::now().to_rfc3339());
let config_dir = confy::get_configuration_file_path("gman", "vault") let config_dir = confy::get_configuration_file_path(&calling_app_name(), "vault")
.with_context(|| "get config dir")? .map_err(|e| SyncError::Config {
message: format!("get config dir: {}", e),
})?
.parent() .parent()
.map(Path::to_path_buf) .map(Path::to_path_buf)
.ok_or_else(|| anyhow!("Failed to determine config dir"))?; .ok_or_else(|| SyncError::Config {
message: "Failed to determine config dir".to_string(),
})?;
let remote_url = opts.remote_url.as_ref().expect("no remote url defined"); let remote_url = opts.remote_url.as_ref().expect("no remote url defined");
let repo_name = repo_name_from_url(remote_url); let repo_name = repo_name_from_url(remote_url);
let repo_dir = config_dir.join(format!(".{}", repo_name)); let repo_dir = config_dir.join(format!(".{}", repo_name));
fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?; fs::create_dir_all(&repo_dir)?;
// Move the default vault into the repo dir on first sync so only vault.yml is tracked. let default_vault = confy::get_configuration_file_path(&calling_app_name(), "vault")
let default_vault = confy::get_configuration_file_path("gman", "vault") .map_err(|e| SyncError::Config {
.with_context(|| "get default vault path")?; message: format!("get default vault path: {}", e),
})?;
let repo_vault = repo_dir.join("vault.yml"); let repo_vault = repo_dir.join("vault.yml");
if default_vault.exists() && !repo_vault.exists() { if default_vault.exists() && !repo_vault.exists() {
fs::rename(&default_vault, &repo_vault).with_context(|| { fs::rename(&default_vault, &repo_vault)?;
format!(
"move {} -> {}",
default_vault.display(),
repo_vault.display()
)
})?;
} else if !repo_vault.exists() { } else if !repo_vault.exists() {
// Ensure an empty vault exists to allow initial commits fs::write(&repo_vault, "{}\n")?;
fs::write(&repo_vault, "{}\n")
.with_context(|| format!("create {}", repo_vault.display()))?;
} }
let git = resolve_git(opts.git_executable.as_ref())?; let git = resolve_git(opts.git_executable.as_ref())?;
@@ -87,48 +122,43 @@ 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)?;
// 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_vault_only(&git, &repo_dir)?; stage_vault_only(&git, &repo_dir)?;
commit_now(&git, &repo_dir, &commit_message)?; commit_now(&git, &repo_dir, &commit_message)?;
run_git( run_git_push(&git, &repo_dir, branch)?;
&git,
&repo_dir,
&["push", "-u", "origin", "--force", branch],
)?;
run_git(&git, &repo_dir, &["remote", "set-head", "origin", "-a"]) run_git(&git, &repo_dir, &["remote", "set-head", "origin", "-a"])
.with_context(|| "Failed to set remote HEAD")
} }
fn resolve_git_username(git: &Path, name: Option<&String>) -> Result<String> { fn resolve_git_username(git: &Path, name: Option<&String>) -> SyncResult<String> {
debug!("Resolving git username"); debug!("Resolving git username");
if let Some(name) = name { if let Some(name) = name {
return Ok(name.to_string()); return Ok(name.to_string());
} }
default_git_username(git) default_git_username(git).map_err(|_| SyncError::Config {
message: "git user.name not configured".to_string(),
})
} }
fn resolve_git_email(git: &Path, email: Option<&String>) -> Result<String> { fn resolve_git_email(git: &Path, email: Option<&String>) -> SyncResult<String> {
debug!("Resolving git user email"); debug!("Resolving git user email");
if let Some(email) = email { if let Some(email) = email {
return Ok(email.to_string()); return Ok(email.to_string());
} }
default_git_email(git) default_git_email(git).map_err(|_| SyncError::Config {
message: "git user.email not configured".to_string(),
})
} }
pub(in crate::providers) fn resolve_git(override_path: Option<&PathBuf>) -> Result<PathBuf> { pub(in crate::providers) fn resolve_git(
override_path: Option<&PathBuf>,
) -> SyncResult<PathBuf> {
debug!("Resolving git executable"); debug!("Resolving git executable");
if let Some(p) = override_path { if let Some(p) = override_path {
return Ok(p.to_path_buf()); return Ok(p.to_path_buf());
@@ -139,63 +169,116 @@ pub(in crate::providers) fn resolve_git(override_path: Option<&PathBuf>) -> Resu
Ok(PathBuf::from("git")) Ok(PathBuf::from("git"))
} }
pub(in crate::providers) fn default_git_username(git: &Path) -> Result<String> { pub(in crate::providers) fn default_git_username(git: &Path) -> SyncResult<String> {
debug!("Checking for default git username"); debug!("Checking for default git username");
run_git_config_capture(git, &["config", "user.name"]) run_git_config_capture(git, &["config", "user.name"]).map_err(|e| SyncError::Config {
.with_context(|| "unable to determine git user name") message: format!("unable to determine git user name: {}", e),
})
} }
pub(in crate::providers) fn default_git_email(git: &Path) -> Result<String> { pub(in crate::providers) fn default_git_email(git: &Path) -> SyncResult<String> {
debug!("Checking for default git username"); debug!("Checking for default git username");
run_git_config_capture(git, &["config", "user.email"]) run_git_config_capture(git, &["config", "user.email"]).map_err(|e| SyncError::Config {
.with_context(|| "unable to determine git user email") message: format!("unable to determine git user email: {}", e),
})
} }
pub(in crate::providers) fn ensure_git_available(git: &Path) -> Result<()> { pub(in crate::providers) fn ensure_git_available(git: &Path) -> SyncResult<()> {
let ok = Command::new(git) let ok = Command::new(git)
.arg("--version") .arg("--version")
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.status() .status()
.context("run git --version")? .map_err(|_| SyncError::GitNotFound)?
.success(); .success();
if !ok { if !ok {
Err(anyhow!("`git` not available on PATH")) Err(SyncError::GitNotFound)
} else { } else {
Ok(()) Ok(())
} }
} }
fn run_git(git: &Path, repo: &Path, args: &[&str]) -> Result<()> { fn run_git(git: &Path, repo: &Path, args: &[&str]) -> SyncResult<()> {
let status = Command::new(git) let out = Command::new(git)
.arg("-C") .arg("-C")
.arg(repo) .arg(repo)
.args(args) .args(args)
.status() .output()?;
.with_context(|| format!("git {}", args.join(" ")))?;
if !status.success() { if !out.status.success() {
return Err(anyhow!("git failed: {}", args.join(" "))); return Err(SyncError::GitCommandFailed {
message: format!(
"git {} (exit {}): {}",
args.join(" "),
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).trim()
),
});
} }
Ok(()) Ok(())
} }
fn run_git_config_capture(git: &Path, args: &[&str]) -> Result<String> { fn run_git_push(git: &Path, repo: &Path, branch: &str) -> SyncResult<()> {
let out = Command::new(git) let out = Command::new(git)
.args(args) .arg("-C")
.output() .arg(repo)
.with_context(|| format!("git {}", args.join(" ")))?; .args(["push", "-u", "origin", "--force", branch])
.output()?;
if !out.status.success() { if !out.status.success() {
return Err(anyhow!( let stderr = String::from_utf8_lossy(&out.stderr).to_string();
"git failed (exit {}): {}", let lc = stderr.to_lowercase();
out.status.code().unwrap_or(-1), let source = anyhow!("git push failed: {}", stderr.trim());
String::from_utf8_lossy(&out.stderr) if lc.contains("authentication failed") || lc.contains("permission denied") {
)); return Err(SyncError::AuthFailed { source });
}
return Err(SyncError::Network { source });
}
Ok(())
}
fn run_git_fetch(git: &Path, repo: &Path) -> SyncResult<()> {
let out = Command::new(git)
.arg("-C")
.arg(repo)
.args(["fetch", "origin", "--prune"])
.output()?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
let lc = stderr.to_lowercase();
let source = anyhow!("git fetch failed: {}", stderr.trim());
if lc.contains("authentication failed") || lc.contains("permission denied") {
return Err(SyncError::AuthFailed { source });
}
return Err(SyncError::Network { source });
}
Ok(())
}
fn run_git_config_capture(git: &Path, args: &[&str]) -> SyncResult<String> {
let out = Command::new(git).args(args).output()?;
if !out.status.success() {
return Err(SyncError::GitCommandFailed {
message: format!(
"git {} (exit {}): {}",
args.join(" "),
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).trim()
),
});
} }
Ok(String::from_utf8_lossy(&out.stdout).to_string()) Ok(String::from_utf8_lossy(&out.stdout).to_string())
} }
fn init_repo_if_needed(git: &Path, repo: &Path, branch: &str) -> Result<()> { fn init_repo_if_needed(git: &Path, repo: &Path, branch: &str) -> SyncResult<()> {
let inside = Command::new(git) let inside = Command::new(git)
.arg("-C") .arg("-C")
.arg(repo) .arg(repo)
@@ -222,19 +305,28 @@ fn init_repo_if_needed(git: &Path, repo: &Path, branch: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn set_local_identity(git: &Path, repo: &Path, username: String, email: String) -> Result<()> { fn set_local_identity(
run_git(git, repo, &["config", "user.name", &username])?; git: &Path,
run_git(git, repo, &["config", "user.email", &email])?; repo: &Path,
username: String,
email: String,
) -> SyncResult<()> {
run_git(git, repo, &["config", "user.name", &username]).map_err(|e| SyncError::Config {
message: format!("failed to set git user.name: {}", e),
})?;
run_git(git, repo, &["config", "user.email", &email]).map_err(|e| SyncError::Config {
message: format!("failed to set git user.email: {}", e),
})?;
Ok(()) Ok(())
} }
fn checkout_branch(git: &Path, repo: &Path, branch: &str) -> Result<()> { fn checkout_branch(git: &Path, repo: &Path, branch: &str) -> SyncResult<()> {
run_git(git, repo, &["checkout", "-B", branch])?; run_git(git, repo, &["checkout", "-B", branch])?;
Ok(()) Ok(())
} }
fn set_origin(git: &Path, repo: &Path, url: &str) -> Result<()> { fn set_origin(git: &Path, repo: &Path, url: &str) -> SyncResult<()> {
let has_origin = Command::new(git) let has_origin = Command::new(git)
.arg("-C") .arg("-C")
.arg(repo) .arg(repo)
@@ -248,49 +340,48 @@ fn set_origin(git: &Path, repo: &Path, url: &str) -> Result<()> {
if has_origin { if has_origin {
run_git(git, repo, &["remote", "set-url", "origin", url])?; run_git(git, repo, &["remote", "set-url", "origin", url])?;
} else if Confirm::with_theme(&ColorfulTheme::default()) } else if Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!("Have you already created the remote origin '{url}' on the Git host so we can push to it?")) .with_prompt(format!(
.default(false) "Have you already created the remote origin '{url}' on the Git host so we can push to it?"
.interact()? ))
{ .default(false)
run_git(git, repo, &["remote", "add", "origin", url])?; .interact()
} else { .map_err(|e| SyncError::Config {
return Err(anyhow!("Remote origin does not yet exist. Please create remote origin before synchronizing, then try again")); message: format!("prompt failed: {}", e),
} })?
{
run_git(git, repo, &["remote", "add", "origin", url])?;
} else {
return Err(SyncError::Config {
message:
"Remote origin does not yet exist. Please create remote origin before synchronizing, then try again"
.to_string(),
});
}
Ok(()) Ok(())
} }
fn stage_vault_only(git: &Path, repo: &Path) -> Result<()> { fn stage_vault_only(git: &Path, repo: &Path) -> SyncResult<()> {
run_git(git, repo, &["add", "vault.yml"])?; run_git(git, repo, &["add", "vault.yml"])?;
Ok(()) Ok(())
} }
fn fetch_and_pull(git: &Path, repo: &Path, branch: &str) -> Result<()> { fn fetch_and_pull(git: &Path, repo: &Path, branch: &str) -> SyncResult<()> {
// Fetch all refs from origin (safe even if branch doesn't exist remotely) run_git_fetch(git, repo)?;
run_git(git, repo, &["fetch", "origin", "--prune"])
.with_context(|| "Failed to fetch changes from remote")?;
let origin_ref = format!("origin/{branch}"); let origin_ref = format!("origin/{branch}");
let remote_has_branch = has_remote_branch(git, repo, 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 !has_head(git, repo) {
if remote_has_branch { if remote_has_branch {
run_git(git, repo, &["checkout", "-f", "-B", branch, &origin_ref]) 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])?;
run_git(git, repo, &["reset", "--hard", &origin_ref]) run_git(git, repo, &["clean", "-fd"])?;
.with_context(|| "Failed to hard reset to remote branch")?;
run_git(git, repo, &["clean", "-fd"])
.with_context(|| "Failed to clean untracked files")?;
} }
return Ok(()); return Ok(());
} }
// If we have local history and the remote branch exists, fast-forward.
if remote_has_branch { if remote_has_branch {
run_git(git, repo, &["merge", "--ff-only", &origin_ref]) run_git(git, repo, &["merge", "--ff-only", &origin_ref])?;
.with_context(|| "Failed to merge remote changes")?;
} }
Ok(()) Ok(())
} }
@@ -324,13 +415,12 @@ fn has_head(git: &Path, repo: &Path) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
fn commit_now(git: &Path, repo: &Path, msg: &str) -> Result<()> { fn commit_now(git: &Path, repo: &Path, msg: &str) -> SyncResult<()> {
let staged_changed = Command::new(git) let staged_changed = Command::new(git)
.arg("-C") .arg("-C")
.arg(repo) .arg(repo)
.args(["diff", "--cached", "--quiet", "--exit-code"]) .args(["diff", "--cached", "--quiet", "--exit-code"])
.status() .status()?
.context("git diff --cached")?
.code() .code()
.map(|c| c == 1) .map(|c| c == 1)
.unwrap_or(false); .unwrap_or(false);
@@ -398,12 +488,10 @@ mod tests {
#[test] #[test]
fn resolve_git_prefers_override_and_env() { fn resolve_git_prefers_override_and_env() {
// Override path wins
let override_path = Some(PathBuf::from("/custom/git")); let override_path = Some(PathBuf::from("/custom/git"));
let got = resolve_git(override_path.as_ref()).unwrap(); let got = resolve_git(override_path.as_ref()).unwrap();
assert_eq!(got, PathBuf::from("/custom/git")); assert_eq!(got, PathBuf::from("/custom/git"));
// If no override, env var is used
unsafe { unsafe {
env::set_var("GIT_EXECUTABLE", "/env/git"); env::set_var("GIT_EXECUTABLE", "/env/git");
} }
+222
View File
@@ -0,0 +1,222 @@
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use validator::Validate;
use crate::providers::error::SecretError;
use crate::providers::{ENV_PATH, SecretProvider};
const PROVIDER: &str = "gopass";
fn map_spawn_err(e: std::io::Error) -> SecretError {
if e.kind() == std::io::ErrorKind::NotFound {
SecretError::CliNotFound { tool: "gopass" }
} else {
SecretError::Io(e)
}
}
#[skip_serializing_none]
/// Gopass-based secret provider
/// See [Gopass](https://gopass.pw/) for more information.
///
/// You must already have gopass installed and configured on your system.
///
/// This provider stores secrets in a gopass store. It requires
/// an optional store name to be specified. If no store name is
/// specified, the default store will be used.
///
/// Example
/// ```no_run
/// use gman::providers::gopass::GopassProvider;
/// use gman::providers::{SecretProvider, SupportedProvider};
/// use gman::config::Config;
///
/// let provider = GopassProvider::default();
/// let _ = provider.set_secret("MY_SECRET", "value");
/// ```
#[derive(Debug, Default, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct GopassProvider {
pub store: Option<String>,
}
#[async_trait::async_trait]
impl SecretProvider for GopassProvider {
fn name(&self) -> &'static str {
"GopassProvider"
}
async fn get_secret(&self, key: &str) -> Result<String, SecretError> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass")
.args(["show", "-yfon", key])
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(map_spawn_err)?;
let mut output = String::new();
child
.stdout
.as_mut()
.expect("Failed to open gopass stdout")
.read_to_string(&mut output)?;
let status = child.wait()?;
if !status.success() {
return Err(SecretError::NotFound {
key: key.to_string(),
provider: PROVIDER,
});
}
Ok(output.trim_end_matches(&['\r', '\n'][..]).to_string())
}
async fn set_secret(&self, key: &str, value: &str) -> Result<(), SecretError> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass")
.args(["insert", "-f", key])
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::piped())
.spawn()
.map_err(map_spawn_err)?;
{
let stdin = child.stdin.as_mut().expect("Failed to open gopass stdin");
stdin.write_all(value.as_bytes())?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.to_lowercase().contains("already exists") {
return Err(SecretError::AlreadyExists {
key: key.to_string(),
provider: PROVIDER,
});
}
return Err(SecretError::Other(anyhow!(
"gopass insert failed: {}",
stderr
)));
}
Ok(())
}
async fn update_secret(&self, key: &str, value: &str) -> Result<(), SecretError> {
ensure_gopass_installed()?;
self.set_secret(key, value).await
}
async fn delete_secret(&self, key: &str) -> Result<(), SecretError> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass")
.args(["rm", "-f", key])
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.map_err(map_spawn_err)?;
let status = child.wait()?;
if !status.success() {
return Err(SecretError::NotFound {
key: key.to_string(),
provider: PROVIDER,
});
}
Ok(())
}
async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass")
.args(["ls", "-f"])
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(map_spawn_err)?;
let mut output = String::new();
child
.stdout
.as_mut()
.expect("Failed to open gopass stdout")
.read_to_string(&mut output)?;
let result = child.wait_with_output()?;
if !result.status.success() {
return Err(SecretError::Other(anyhow!(
"gopass ls failed: {}",
String::from_utf8_lossy(&result.stderr)
)));
}
let secrets: Vec<String> = output
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect();
Ok(secrets)
}
async fn sync(&mut self) -> Result<(), SecretError> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass");
child.arg("sync");
if let Some(store) = &self.store {
child.args(["-s", store]);
}
let output = child
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::piped())
.spawn()
.map_err(map_spawn_err)?
.wait_with_output()?;
if !output.status.success() {
return Err(SecretError::Network {
provider: PROVIDER,
source: anyhow!(
"gopass sync failed: {}",
String::from_utf8_lossy(&output.stderr)
),
});
}
Ok(())
}
}
fn ensure_gopass_installed() -> Result<(), SecretError> {
if which::which("gopass").is_err() {
Err(SecretError::CliNotFound { tool: "gopass" })
} else {
Ok(())
}
}
+268 -113
View File
@@ -1,20 +1,8 @@
use anyhow::{Context, anyhow, bail};
use secrecy::{ExposeSecret, SecretString};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::{env, fs}; use std::{env, fs};
use zeroize::Zeroize;
use crate::config::{Config, get_config_file_path, load_config}; use anyhow::{Context as _, anyhow};
use crate::providers::git_sync::{
SyncOpts, default_git_email, default_git_username, ensure_git_available, repo_name_from_url,
resolve_git, sync_and_push,
};
use crate::providers::{SecretProvider, SupportedProvider};
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 argon2::{Algorithm, Argon2, Params, Version};
use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
use chacha20poly1305::aead::rand_core::RngCore; use chacha20poly1305::aead::rand_core::RngCore;
@@ -24,10 +12,35 @@ use chacha20poly1305::{
}; };
use dialoguer::{Input, theme}; use dialoguer::{Input, theme};
use log::{debug, error}; use log::{debug, error};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
use theme::ColorfulTheme; use theme::ColorfulTheme;
use validator::Validate; use validator::Validate;
use zeroize::Zeroize;
use crate::config::{Config, get_config_file_path, load_config};
use crate::providers::error::SecretError;
use crate::providers::git_sync::{
SyncOpts, default_git_email, default_git_username, ensure_git_available, repo_name_from_url,
resolve_git, sync_and_push,
};
use crate::providers::{SecretProvider, SupportedProvider};
use crate::{
ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION,
calling_app_name,
};
const PROVIDER: &str = "local";
type LocalResult<T> = std::result::Result<T, SecretError>;
fn cfg_err(message: impl Into<String>) -> SecretError {
SecretError::Config {
provider: PROVIDER,
message: message.into(),
}
}
#[skip_serializing_none] #[skip_serializing_none]
/// File-based vault provider with optional Git sync. /// File-based vault provider with optional Git sync.
@@ -63,8 +76,13 @@ pub struct LocalProvider {
impl Default for LocalProvider { impl Default for LocalProvider {
fn default() -> Self { fn default() -> Self {
let password_file = match Config::local_provider_password_file() {
p if p.exists() => Some(p),
_ => None,
};
Self { Self {
password_file: Config::local_provider_password_file(), 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,
@@ -81,12 +99,13 @@ impl SecretProvider for LocalProvider {
"LocalProvider" "LocalProvider"
} }
async fn get_secret(&self, key: &str) -> Result<String> { async fn get_secret(&self, key: &str) -> LocalResult<String> {
let vault_path = self.active_vault_path()?; let vault_path = self.active_vault_path()?;
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let envelope = vault let envelope = vault.get(key).ok_or_else(|| SecretError::NotFound {
.get(key) key: key.to_string(),
.with_context(|| format!("key '{key}' not found in the vault"))?; provider: PROVIDER,
})?;
let password = self.get_password()?; let password = self.get_password()?;
let plaintext = decrypt_string(&password, envelope)?; let plaintext = decrypt_string(&password, envelope)?;
@@ -95,69 +114,78 @@ impl SecretProvider for LocalProvider {
Ok(plaintext) Ok(plaintext)
} }
async fn set_secret(&self, key: &str, value: &str) -> Result<()> { async fn set_secret(&self, key: &str, value: &str) -> LocalResult<()> {
let vault_path = self.active_vault_path()?; let vault_path = self.active_vault_path()?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if vault.contains_key(key) { if vault.contains_key(key) {
error!( error!(
"Key '{key}' already exists in the vault. Use a different key or delete the existing one first." "Key '{key}' already exists in the vault. Use a different key or delete the existing one first."
); );
bail!("key '{key}' already exists"); return Err(SecretError::AlreadyExists {
key: key.to_string(),
provider: PROVIDER,
});
} }
let password = self.get_password()?; let password = self.get_password()?;
let envelope = encrypt_string(&password, value)?; let envelope =
encrypt_string(&password, value).map_err(SecretError::Other)?;
drop(password); drop(password);
vault.insert(key.to_string(), envelope); vault.insert(key.to_string(), envelope);
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") store_vault(&vault_path, &vault)
} }
async fn update_secret(&self, key: &str, value: &str) -> Result<()> { async fn update_secret(&self, key: &str, value: &str) -> LocalResult<()> {
let vault_path = self.active_vault_path()?; let vault_path = self.active_vault_path()?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let password = self.get_password()?; let password = self.get_password()?;
let envelope = encrypt_string(&password, value)?; let envelope =
encrypt_string(&password, value).map_err(SecretError::Other)?;
drop(password); drop(password);
if vault.contains_key(key) { if vault.contains_key(key) {
debug!("Key '{key}' exists in vault. Overwriting previous value"); debug!("Key '{key}' exists in vault. Overwriting previous value");
let vault_entry = vault let vault_entry = vault.get_mut(key).ok_or_else(|| SecretError::NotFound {
.get_mut(key) key: key.to_string(),
.with_context(|| format!("key '{key}' not found in the vault"))?; provider: PROVIDER,
})?;
*vault_entry = envelope; *vault_entry = envelope;
return store_vault(&vault_path, &vault) return store_vault(&vault_path, &vault);
.with_context(|| "failed to save secret to the vault");
} }
vault.insert(key.to_string(), envelope); vault.insert(key.to_string(), envelope);
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") store_vault(&vault_path, &vault)
} }
async fn delete_secret(&self, key: &str) -> Result<()> { async fn delete_secret(&self, key: &str) -> LocalResult<()> {
let vault_path = self.active_vault_path()?; let vault_path = self.active_vault_path()?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if !vault.contains_key(key) { if !vault.contains_key(key) {
error!("Key '{key}' does not exist in the vault."); error!("Key '{key}' does not exist in the vault.");
bail!("key '{key}' does not exist"); return Err(SecretError::NotFound {
key: key.to_string(),
provider: PROVIDER,
});
} }
vault.remove(key); vault.remove(key);
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") store_vault(&vault_path, &vault)
} }
async fn list_secrets(&self) -> Result<Vec<String>> { async fn list_secrets(&self) -> LocalResult<Vec<String>> {
let vault_path = self.active_vault_path()?; let vault_path = self.active_vault_path()?;
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let keys: Vec<String> = vault.keys().cloned().collect(); let mut keys: Vec<String> = vault.keys().cloned().collect();
keys.sort();
Ok(keys) Ok(keys)
} }
async fn sync(&mut self) -> Result<()> { async fn sync(&mut self) -> LocalResult<()> {
let mut config_changed = false; let mut config_changed = false;
let git = resolve_git(self.git_executable.as_ref())?; let git = resolve_git(self.git_executable.as_ref())?;
ensure_git_available(&git)?; ensure_git_available(&git)?;
@@ -168,7 +196,8 @@ impl SecretProvider for LocalProvider {
let branch: String = Input::with_theme(&ColorfulTheme::default()) let branch: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter git branch to sync with") .with_prompt("Enter git branch to sync with")
.default("main".into()) .default("main".into())
.interact_text()?; .interact_text()
.map_err(|e| cfg_err(format!("prompt failed: {}", e)))?;
self.git_branch = Some(branch); self.git_branch = Some(branch);
} }
@@ -189,7 +218,8 @@ impl SecretProvider for LocalProvider {
.map(|_| ()) .map(|_| ())
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) })
.interact_text()?; .interact_text()
.map_err(|e| cfg_err(format!("prompt failed: {}", e)))?;
self.git_remote_url = Some(remote); self.git_remote_url = Some(remote);
} }
@@ -197,11 +227,15 @@ impl SecretProvider for LocalProvider {
if self.git_user_name.is_none() { if self.git_user_name.is_none() {
config_changed = true; config_changed = true;
debug!("Prompting user git user name"); debug!("Prompting user git user name");
let default_user_name = default_git_username(&git)?.trim().to_string(); let default_user_name = default_git_username(&git)
.map_err(SecretError::from)?
.trim()
.to_string();
let branch: String = Input::with_theme(&ColorfulTheme::default()) let branch: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter git user name") .with_prompt("Enter git user name")
.default(default_user_name) .default(default_user_name)
.interact_text()?; .interact_text()
.map_err(|e| cfg_err(format!("prompt failed: {}", e)))?;
self.git_user_name = Some(branch); self.git_user_name = Some(branch);
} }
@@ -209,7 +243,10 @@ impl SecretProvider for LocalProvider {
if self.git_user_email.is_none() { if self.git_user_email.is_none() {
config_changed = true; config_changed = true;
debug!("Prompting user git email"); debug!("Prompting user git email");
let default_user_name = default_git_email(&git)?.trim().to_string(); let default_user_name = default_git_email(&git)
.map_err(SecretError::from)?
.trim()
.to_string();
let branch: String = Input::with_theme(&ColorfulTheme::default()) let branch: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter git user email") .with_prompt("Enter git user email")
.validate_with({ .validate_with({
@@ -222,7 +259,8 @@ impl SecretProvider for LocalProvider {
} }
}) })
.default(default_user_name) .default(default_user_name)
.interact_text()?; .interact_text()
.map_err(|e| cfg_err(format!("prompt failed: {}", e)))?;
self.git_user_email = Some(branch); self.git_user_email = Some(branch);
} }
@@ -239,15 +277,17 @@ impl SecretProvider for LocalProvider {
git_executable: &self.git_executable, git_executable: &self.git_executable,
}; };
sync_and_push(&sync_opts) sync_and_push(&sync_opts)?;
Ok(())
} }
} }
impl LocalProvider { impl LocalProvider {
fn persist_git_settings_to_config(&self) -> Result<()> { fn persist_git_settings_to_config(&self) -> LocalResult<()> {
debug!("Saving updated config (only current local provider)"); debug!("Saving updated config (only current local provider)");
let mut cfg = load_config().with_context(|| "failed to load existing config")?; let mut cfg = load_config(true)
.map_err(|e| cfg_err(format!("failed to load existing config: {}", e)))?;
let target_name = self.runtime_provider_name.clone(); let target_name = self.runtime_provider_name.clone();
let mut updated = false; let mut updated = false;
@@ -274,26 +314,30 @@ impl LocalProvider {
} }
if !updated { if !updated {
bail!("unable to find matching local provider in config to update"); return Err(cfg_err(
"unable to find matching local provider in config to update",
));
} }
let path = get_config_file_path()?; let path = get_config_file_path()
.map_err(|e| cfg_err(format!("failed to determine config path: {}", e)))?;
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or(""); let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
if ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml") { if ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml") {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
let s = serde_yaml::to_string(&cfg)?; let s = serde_yaml::to_string(&cfg)
fs::write(&path, s).with_context(|| format!("failed to write {}", path.display()))?; .map_err(|e| cfg_err(format!("serialize config: {}", e)))?;
fs::write(&path, s)?;
} else { } else {
confy::store("gman", "config", &cfg) confy::store(&calling_app_name(), "config", &cfg)
.with_context(|| "failed to save updated config via confy")?; .map_err(|e| cfg_err(format!("failed to save updated config via confy: {}", e)))?;
} }
Ok(()) Ok(())
} }
fn repo_dir_for_config(&self) -> Result<Option<PathBuf>> { fn repo_dir_for_config(&self) -> LocalResult<Option<PathBuf>> {
if let Some(remote) = &self.git_remote_url { if let Some(remote) = &self.git_remote_url {
let name = repo_name_from_url(remote); let name = repo_name_from_url(remote);
let dir = base_config_dir()?.join(format!(".{}", name)); let dir = base_config_dir()?.join(format!(".{}", name));
@@ -303,7 +347,7 @@ impl LocalProvider {
} }
} }
fn active_vault_path(&self) -> Result<PathBuf> { fn active_vault_path(&self) -> LocalResult<PathBuf> {
if let Some(dir) = self.repo_dir_for_config()? if let Some(dir) = self.repo_dir_for_config()?
&& dir.exists() && dir.exists()
{ {
@@ -313,11 +357,24 @@ impl LocalProvider {
default_vault_path() default_vault_path()
} }
fn get_password(&self) -> Result<SecretString> { fn get_password(&self) -> LocalResult<SecretString> {
if let Some(password_file) = &self.password_file { if let Some(password_file) = &self.password_file {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(password_file)?;
let mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
return Err(cfg_err(format!(
"password file {:?} has insecure permissions {:o} (should be 0600 or 0400)",
password_file,
mode & 0o777
)));
}
}
let password = SecretString::new( let password = SecretString::new(
fs::read_to_string(password_file) fs::read_to_string(password_file)?
.with_context(|| format!("failed to read password file {:?}", password_file))?
.trim() .trim()
.to_string() .to_string()
.into(), .into(),
@@ -331,24 +388,25 @@ impl LocalProvider {
} }
} }
fn default_vault_path() -> Result<PathBuf> { fn default_vault_path() -> LocalResult<PathBuf> {
let xdg_path = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from); let xdg_path = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from);
if let Some(xdg) = xdg_path { if let Some(xdg) = xdg_path {
return Ok(xdg.join("gman").join("vault.yml")); return Ok(xdg.join(calling_app_name()).join("vault.yml"));
} }
confy::get_configuration_file_path("gman", "vault").with_context(|| "get config dir") confy::get_configuration_file_path(&calling_app_name(), "vault")
.map_err(|e| cfg_err(format!("get config dir: {}", e)))
} }
fn base_config_dir() -> Result<PathBuf> { fn base_config_dir() -> LocalResult<PathBuf> {
default_vault_path()? default_vault_path()?
.parent() .parent()
.map(Path::to_path_buf) .map(Path::to_path_buf)
.ok_or_else(|| anyhow!("Failed to determine config dir")) .ok_or_else(|| cfg_err("Failed to determine config dir"))
} }
fn load_vault(path: &Path) -> Result<HashMap<String, String>> { fn load_vault(path: &Path) -> anyhow::Result<HashMap<String, String>> {
if !path.exists() { if !path.exists() {
return Ok(HashMap::new()); return Ok(HashMap::new());
} }
@@ -357,29 +415,46 @@ fn load_vault(path: &Path) -> Result<HashMap<String, String>> {
Ok(map) Ok(map)
} }
fn store_vault(path: &Path, map: &HashMap<String, String>) -> Result<()> { fn store_vault(path: &Path, map: &HashMap<String, String>) -> LocalResult<()> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; fs::create_dir_all(parent)?;
} }
let s = serde_yaml::to_string(map).with_context(|| "serialize vault")?; let s = serde_yaml::to_string(map)
fs::write(path, s).with_context(|| format!("write {}", path.display())) .map_err(|e| SecretError::Other(anyhow!("serialize vault: {}", e)))?;
fs::write(path, &s)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
}
Ok(())
} }
fn encrypt_string(password: &SecretString, plaintext: &str) -> Result<String> { fn encrypt_string(password: &SecretString, plaintext: &str) -> anyhow::Result<String> {
if password.expose_secret().is_empty() {
anyhow::bail!("password cannot be empty");
}
let mut salt = [0u8; SALT_LEN]; let mut salt = [0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt); OsRng.fill_bytes(&mut salt);
let mut nonce_bytes = [0u8; NONCE_LEN]; let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes); OsRng.fill_bytes(&mut nonce_bytes);
let key = derive_key(password, &salt)?; let mut key = derive_key(password, &salt)?;
let cipher = XChaCha20Poly1305::new(&key); let cipher = XChaCha20Poly1305::new(&key);
let aad = format!("{};{}", HEADER, VERSION);
let nonce = XNonce::from_slice(&nonce_bytes); let aad = format!(
"{};{};{};m={},t={},p={}",
HEADER, VERSION, KDF, ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P
);
let nonce: XNonce = nonce_bytes.into();
let mut pt = plaintext.as_bytes().to_vec(); let mut pt = plaintext.as_bytes().to_vec();
let ct = cipher let ct = cipher
.encrypt( .encrypt(
nonce, &nonce,
chacha20poly1305::aead::Payload { chacha20poly1305::aead::Payload {
msg: &pt, msg: &pt,
aad: aad.as_bytes(), aad: aad.as_bytes(),
@@ -402,6 +477,7 @@ fn encrypt_string(password: &SecretString, plaintext: &str) -> Result<String> {
); );
drop(cipher); drop(cipher);
key.zeroize();
salt.zeroize(); salt.zeroize();
nonce_bytes.zeroize(); nonce_bytes.zeroize();
@@ -414,7 +490,7 @@ fn derive_key_with_params(
m_cost: u32, m_cost: u32,
t_cost: u32, t_cost: u32,
p: u32, p: u32,
) -> Result<Key> { ) -> anyhow::Result<Key> {
let params = Params::new(m_cost, t_cost, p, Some(KEY_LEN)) let params = Params::new(m_cost, t_cost, p, Some(KEY_LEN))
.map_err(|e| anyhow!("argon2 params error: {:?}", e))?; .map_err(|e| anyhow!("argon2 params error: {:?}", e))?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
@@ -422,32 +498,49 @@ fn derive_key_with_params(
argon argon
.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 derive error: {:?}", e))?; .map_err(|e| anyhow!("argon2 derive error: {:?}", e))?;
let key: Key = key_bytes.into();
key_bytes.zeroize(); key_bytes.zeroize();
let key = Key::from_slice(&key_bytes); Ok(key)
Ok(*key)
} }
fn derive_key(password: &SecretString, salt: &[u8]) -> Result<Key> { fn derive_key(password: &SecretString, salt: &[u8]) -> anyhow::Result<Key> {
derive_key_with_params(password, salt, ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P) derive_key_with_params(password, salt, ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P)
} }
fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> { fn try_decrypt(
cipher: &XChaCha20Poly1305,
nonce: &XNonce,
ct: &[u8],
aad: &[u8],
) -> std::result::Result<Vec<u8>, chacha20poly1305::aead::Error> {
cipher.decrypt(nonce, chacha20poly1305::aead::Payload { msg: ct, aad })
}
type EnvelopeComponents = (u32, u32, u32, Vec<u8>, [u8; NONCE_LEN], Vec<u8>);
fn parse_envelope(envelope: &str) -> LocalResult<EnvelopeComponents> {
let parts: Vec<&str> = envelope.trim().split(';').collect(); let parts: Vec<&str> = envelope.trim().split(';').collect();
if parts.len() < 7 { if parts.len() < 7 {
debug!("Invalid envelope format: {:?}", parts); debug!("Invalid envelope format: {:?}", parts);
bail!("invalid envelope format"); return Err(SecretError::Other(anyhow!("invalid envelope format")));
} }
if parts[0] != HEADER { if parts[0] != HEADER {
debug!("Invalid header: {}", parts[0]); debug!("Invalid header: {}", parts[0]);
bail!("unexpected header"); return Err(SecretError::Other(anyhow!("unexpected header")));
} }
if parts[1] != VERSION { if parts[1] != VERSION {
debug!("Unsupported version: {}", parts[1]); debug!("Unsupported version: {}", parts[1]);
bail!("unsupported version {}", parts[1]); return Err(SecretError::Unsupported {
operation: "decrypt_envelope_version",
provider: PROVIDER,
});
} }
if parts[2] != KDF { if parts[2] != KDF {
debug!("Unsupported kdf: {}", parts[2]); debug!("Unsupported kdf: {}", parts[2]);
bail!("unsupported kdf {}", parts[2]); return Err(SecretError::Unsupported {
operation: "decrypt_kdf",
provider: PROVIDER,
});
} }
let params_str = parts[3]; let params_str = parts[3];
@@ -467,62 +560,90 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> {
let salt_b64 = parts[4] let salt_b64 = parts[4]
.strip_prefix("salt=") .strip_prefix("salt=")
.with_context(|| "missing salt")?; .ok_or_else(|| SecretError::Other(anyhow!("missing salt")))?;
let nonce_b64 = parts[5] let nonce_b64 = parts[5]
.strip_prefix("nonce=") .strip_prefix("nonce=")
.with_context(|| "missing nonce")?; .ok_or_else(|| SecretError::Other(anyhow!("missing nonce")))?;
let ct_b64 = parts[6].strip_prefix("ct=").with_context(|| "missing ct")?; let ct_b64 = parts[6]
.strip_prefix("ct=")
.ok_or_else(|| SecretError::Other(anyhow!("missing ct")))?;
let mut salt = B64.decode(salt_b64).with_context(|| "bad salt b64")?; let salt = B64
let mut nonce_bytes = B64.decode(nonce_b64).with_context(|| "bad nonce b64")?; .decode(salt_b64)
let mut ct = B64.decode(ct_b64).with_context(|| "bad ct b64")?; .map_err(|e| SecretError::Other(anyhow!("bad salt b64: {}", e)))?;
let nonce_bytes = B64
.decode(nonce_b64)
.map_err(|e| SecretError::Other(anyhow!("bad nonce b64: {}", e)))?;
let ct = B64
.decode(ct_b64)
.map_err(|e| SecretError::Other(anyhow!("bad ct b64: {}", e)))?;
if salt.len() != SALT_LEN || nonce_bytes.len() != NONCE_LEN { if nonce_bytes.len() != NONCE_LEN {
debug!( debug!("Nonce length mismatch: {}", nonce_bytes.len());
"Salt/nonce length mismatch: salt {}, nonce {}", return Err(SecretError::Other(anyhow!("nonce length mismatch")));
salt.len(),
nonce_bytes.len()
);
bail!("salt/nonce length mismatch");
} }
let key = derive_key_with_params(password, &salt, m, t, p)?; let nonce_arr: [u8; NONCE_LEN] = nonce_bytes
.try_into()
.map_err(|_| SecretError::Other(anyhow!("invalid nonce length")))?;
Ok((m, t, p, salt, nonce_arr, ct))
}
fn decrypt_string(password: &SecretString, envelope: &str) -> LocalResult<String> {
if password.expose_secret().is_empty() {
return Err(cfg_err("password cannot be empty"));
}
let (m, t, p, mut salt, mut nonce_arr, mut ct) = parse_envelope(envelope)?;
let nonce: XNonce = nonce_arr.into();
let aad_current = format!("{};{};{};m={},t={},p={}", HEADER, VERSION, KDF, m, t, p);
let mut key = derive_key_with_params(password, &salt, m, t, p)
.map_err(|source| SecretError::AuthFailed {
provider: PROVIDER,
source,
})?;
let cipher = XChaCha20Poly1305::new(&key); let cipher = XChaCha20Poly1305::new(&key);
let aad = format!("{};{}", HEADER, VERSION);
let nonce = XNonce::from_slice(&nonce_bytes);
let pt = cipher if let Ok(pt) = try_decrypt(&cipher, &nonce, &ct, aad_current.as_bytes()) {
.decrypt( let s = String::from_utf8(pt.clone())
nonce, .map_err(|e| SecretError::Other(anyhow!("plaintext not valid UTF-8: {}", e)))?;
chacha20poly1305::aead::Payload { key.zeroize();
msg: &ct, salt.zeroize();
aad: aad.as_bytes(), nonce_arr.zeroize();
}, ct.zeroize();
) return Ok(s);
.map_err(|_| anyhow!("decryption failed (wrong password or corrupted data)"))?; }
key.zeroize();
salt.zeroize(); salt.zeroize();
nonce_bytes.zeroize(); nonce_arr.zeroize();
ct.zeroize(); ct.zeroize();
let s = String::from_utf8(pt).with_context(|| "plaintext not valid UTF-8")?; Err(SecretError::AuthFailed {
Ok(s) provider: PROVIDER,
source: anyhow!("decryption failed (wrong password or corrupted data)"),
})
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use std::env as std_env;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use std::env as std_env;
use tempfile::tempdir; use tempfile::tempdir;
use super::*;
#[test] #[test]
fn test_derive_key() { fn test_derive_key() {
let password = SecretString::new("test_password".to_string().into()); let password = SecretString::new("test_password".to_string().into());
let salt = [0u8; 16]; let salt = [0u8; 16];
let key = derive_key(&password, &salt).unwrap(); let key = derive_key(&password, &salt).unwrap();
assert_eq!(key.as_slice().len(), 32); assert_eq!(key.len(), 32);
} }
#[test] #[test]
@@ -530,7 +651,7 @@ mod tests {
let password = SecretString::new("test_password".to_string().into()); let password = SecretString::new("test_password".to_string().into());
let salt = [0u8; 16]; let salt = [0u8; 16];
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.len(), 32);
} }
#[test] #[test]
@@ -543,6 +664,40 @@ mod tests {
} }
#[test] #[test]
#[cfg(unix)]
fn get_password_reads_password_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let file = dir.path().join("pw.txt");
fs::write(&file, "secretpw\n").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap();
let provider = LocalProvider {
password_file: Some(file),
runtime_provider_name: None,
..LocalProvider::default()
};
let pw = provider.get_password().unwrap();
assert_eq!(pw.expose_secret(), "secretpw");
}
#[test]
#[cfg(unix)]
fn get_password_rejects_insecure_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let file = dir.path().join("pw.txt");
fs::write(&file, "secretpw\n").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
let provider = LocalProvider {
password_file: Some(file),
runtime_provider_name: None,
..LocalProvider::default()
};
assert!(provider.get_password().is_err());
}
#[test]
#[cfg(not(unix))]
fn get_password_reads_password_file() { fn get_password_reads_password_file() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let file = dir.path().join("pw.txt"); let file = dir.path().join("pw.txt");
@@ -560,7 +715,7 @@ mod tests {
fn persist_only_target_local_provider_git_settings() { fn persist_only_target_local_provider_git_settings() {
let td = tempdir().unwrap(); let td = tempdir().unwrap();
let xdg = td.path().join("xdg"); let xdg = td.path().join("xdg");
let app_dir = xdg.join("gman"); let app_dir = xdg.join(calling_app_name());
fs::create_dir_all(&app_dir).unwrap(); fs::create_dir_all(&app_dir).unwrap();
unsafe { unsafe {
std_env::set_var("XDG_CONFIG_HOME", &xdg); std_env::set_var("XDG_CONFIG_HOME", &xdg);
+57 -26
View File
@@ -4,44 +4,58 @@
//! interface used by the CLI. //! interface used by the CLI.
pub mod aws_secrets_manager; pub mod aws_secrets_manager;
pub mod azure_key_vault; pub mod azure_key_vault;
pub mod error;
pub mod gcp_secret_manager; pub mod gcp_secret_manager;
mod git_sync; pub mod git_sync;
pub mod gopass;
pub mod local; pub mod local;
pub mod one_password;
use crate::providers::local::LocalProvider;
use anyhow::{Result, anyhow};
use aws_secrets_manager::AwsSecretsManagerProvider;
use gcp_secret_manager::GcpSecretManagerProvider;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::{env, fmt};
use anyhow::{Context, Result};
use aws_secrets_manager::AwsSecretsManagerProvider;
use azure_key_vault::AzureKeyVaultProvider;
use gcp_secret_manager::GcpSecretManagerProvider;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationErrors}; use validator::{Validate, ValidationErrors};
pub use crate::providers::error::SecretError;
pub use crate::providers::git_sync::SyncError;
use crate::providers::gopass::GopassProvider;
use crate::providers::local::LocalProvider;
use crate::providers::one_password::OnePasswordProvider;
pub(in crate::providers) static ENV_PATH: Lazy<Result<String>> =
Lazy::new(|| env::var("PATH").context("No PATH environment variable"));
/// A secret storage backend capable of CRUD, with optional /// A secret storage backend capable of CRUD, with optional
/// update, listing, and sync support. /// update, listing, and sync support.
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait SecretProvider: Send + Sync { pub trait SecretProvider: Send + Sync {
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
async fn get_secret(&self, key: &str) -> Result<String>; async fn get_secret(&self, key: &str) -> Result<String, SecretError>;
async fn set_secret(&self, key: &str, value: &str) -> Result<()>; async fn set_secret(&self, key: &str, value: &str) -> Result<(), SecretError>;
async fn update_secret(&self, _key: &str, _value: &str) -> Result<()> { async fn update_secret(&self, _key: &str, _value: &str) -> Result<(), SecretError> {
Err(anyhow!( Err(SecretError::Unsupported {
"update secret not supported for provider {}", operation: "update_secret",
self.name() provider: self.name(),
)) })
} }
async fn delete_secret(&self, key: &str) -> Result<()>; async fn delete_secret(&self, key: &str) -> Result<(), SecretError>;
async fn list_secrets(&self) -> Result<Vec<String>> { async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
Err(anyhow!( Err(SecretError::Unsupported {
"list secrets is not supported for the provider {}", operation: "list_secrets",
self.name() provider: self.name(),
)) })
} }
async fn sync(&mut self) -> Result<()> { async fn sync(&mut self) -> Result<(), SecretError> {
Err(anyhow!( Err(SecretError::Unsupported {
"sync is not supported for the provider {}", operation: "sync",
self.name() provider: self.name(),
)) })
} }
} }
@@ -63,7 +77,15 @@ pub enum SupportedProvider {
}, },
AzureKeyVault { AzureKeyVault {
#[serde(flatten)] #[serde(flatten)]
provider_def: azure_key_vault::AzureKeyVaultProvider, provider_def: AzureKeyVaultProvider,
},
Gopass {
#[serde(flatten)]
provider_def: GopassProvider,
},
OnePassword {
#[serde(flatten)]
provider_def: OnePasswordProvider,
}, },
} }
@@ -74,6 +96,8 @@ impl Validate for SupportedProvider {
SupportedProvider::AwsSecretsManager { provider_def } => provider_def.validate(), SupportedProvider::AwsSecretsManager { provider_def } => provider_def.validate(),
SupportedProvider::GcpSecretManager { provider_def } => provider_def.validate(), SupportedProvider::GcpSecretManager { provider_def } => provider_def.validate(),
SupportedProvider::AzureKeyVault { provider_def } => provider_def.validate(), SupportedProvider::AzureKeyVault { provider_def } => provider_def.validate(),
SupportedProvider::Gopass { provider_def } => provider_def.validate(),
SupportedProvider::OnePassword { provider_def } => provider_def.validate(),
} }
} }
} }
@@ -93,6 +117,13 @@ impl Display for SupportedProvider {
SupportedProvider::AwsSecretsManager { .. } => write!(f, "aws_secrets_manager"), SupportedProvider::AwsSecretsManager { .. } => write!(f, "aws_secrets_manager"),
SupportedProvider::GcpSecretManager { .. } => write!(f, "gcp_secret_manager"), SupportedProvider::GcpSecretManager { .. } => write!(f, "gcp_secret_manager"),
SupportedProvider::AzureKeyVault { .. } => write!(f, "azure_key_vault"), SupportedProvider::AzureKeyVault { .. } => write!(f, "azure_key_vault"),
SupportedProvider::Gopass { .. } => write!(f, "gopass"),
SupportedProvider::OnePassword { .. } => write!(f, "one_password"),
} }
} }
} }
#[allow(unused_imports)]
pub(crate) use crate::providers::error::{
classify_aws_error, classify_azure_error, classify_gcp_error,
};
+232
View File
@@ -0,0 +1,232 @@
use std::io::Read;
use std::process::{Command, Stdio};
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use validator::Validate;
use crate::providers::error::SecretError;
use crate::providers::{ENV_PATH, SecretProvider};
const PROVIDER: &str = "one_password";
fn map_spawn_err(e: std::io::Error) -> SecretError {
if e.kind() == std::io::ErrorKind::NotFound {
SecretError::CliNotFound { tool: "op" }
} else {
SecretError::Io(e)
}
}
#[skip_serializing_none]
/// 1Password-based secret provider.
/// See [1Password CLI](https://developer.1password.com/docs/cli/) for more
/// information.
///
/// You must already have the 1Password CLI (`op`) installed and configured
/// on your system.
///
/// This provider stores secrets as 1Password Password items. It requires
/// an optional vault name and an optional account identifier to be specified.
/// If no vault is specified, the user's default vault is used. If no account
/// is specified, the default signed-in account is used.
///
/// Example
/// ```no_run
/// use gman::providers::one_password::OnePasswordProvider;
/// use gman::providers::{SecretProvider, SupportedProvider};
/// use gman::config::Config;
///
/// let provider = OnePasswordProvider::default();
/// let _ = provider.set_secret("MY_SECRET", "value");
/// ```
#[derive(Debug, Default, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct OnePasswordProvider {
pub vault: Option<String>,
pub account: Option<String>,
}
impl OnePasswordProvider {
fn base_command(&self) -> Command {
let mut cmd = Command::new("op");
cmd.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"));
if let Some(account) = &self.account {
cmd.args(["--account", account]);
}
cmd
}
fn vault_args(&self) -> Vec<&str> {
match &self.vault {
Some(vault) => vec!["--vault", vault],
None => vec![],
}
}
}
fn classify_op_stderr(stderr: &str, key: Option<&str>) -> SecretError {
let lc = stderr.to_lowercase();
if lc.contains("isn't an item") || lc.contains("doesn't exist") || lc.contains("not found") {
SecretError::NotFound {
key: key.unwrap_or("").to_string(),
provider: PROVIDER,
}
} else if lc.contains("not currently signed in")
|| lc.contains("session expired")
|| lc.contains("not signed in")
{
SecretError::AuthFailed {
provider: PROVIDER,
source: anyhow!("op auth error: {}", stderr.trim()),
}
} else {
SecretError::Other(anyhow!("op command failed: {}", stderr.trim()))
}
}
#[async_trait::async_trait]
impl SecretProvider for OnePasswordProvider {
fn name(&self) -> &'static str {
"OnePasswordProvider"
}
async fn get_secret(&self, key: &str) -> Result<String, SecretError> {
ensure_op_installed()?;
let mut cmd = self.base_command();
cmd.args(["item", "get", key, "--fields", "password", "--reveal"]);
cmd.args(self.vault_args());
cmd.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(map_spawn_err)?;
let mut output = String::new();
child
.stdout
.as_mut()
.expect("Failed to open op stdout")
.read_to_string(&mut output)?;
let result = child.wait_with_output()?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
return Err(classify_op_stderr(&stderr, Some(key)));
}
Ok(output.trim_end_matches(&['\r', '\n'][..]).to_string())
}
async fn set_secret(&self, key: &str, value: &str) -> Result<(), SecretError> {
ensure_op_installed()?;
let mut cmd = self.base_command();
cmd.args(["item", "create", "--category", "password", "--title", key]);
cmd.args(self.vault_args());
cmd.arg(format!("password={}", value));
cmd.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child = cmd.spawn().map_err(map_spawn_err)?;
let result = child.wait_with_output()?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
return Err(classify_op_stderr(&stderr, Some(key)));
}
Ok(())
}
async fn update_secret(&self, key: &str, value: &str) -> Result<(), SecretError> {
ensure_op_installed()?;
let mut cmd = self.base_command();
cmd.args(["item", "edit", key]);
cmd.args(self.vault_args());
cmd.arg(format!("password={}", value));
cmd.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child = cmd.spawn().map_err(map_spawn_err)?;
let result = child.wait_with_output()?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
return Err(classify_op_stderr(&stderr, Some(key)));
}
Ok(())
}
async fn delete_secret(&self, key: &str) -> Result<(), SecretError> {
ensure_op_installed()?;
let mut cmd = self.base_command();
cmd.args(["item", "delete", key]);
cmd.args(self.vault_args());
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::piped());
let child = cmd.spawn().map_err(map_spawn_err)?;
let result = child.wait_with_output()?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
return Err(classify_op_stderr(&stderr, Some(key)));
}
Ok(())
}
async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
ensure_op_installed()?;
let mut cmd = self.base_command();
cmd.args(["item", "list", "--format", "json"]);
cmd.args(self.vault_args());
cmd.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(map_spawn_err)?;
let mut output = String::new();
child
.stdout
.as_mut()
.expect("Failed to open op stdout")
.read_to_string(&mut output)?;
let result = child.wait_with_output()?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
return Err(classify_op_stderr(&stderr, None));
}
let items: Vec<serde_json::Value> = serde_json::from_str(&output)
.map_err(|e| SecretError::Other(anyhow!("failed to parse op output: {}", e)))?;
let secrets: Vec<String> = items
.iter()
.filter_map(|item| item.get("title").and_then(|t| t.as_str()))
.map(|s| s.to_string())
.collect();
Ok(secrets)
}
}
fn ensure_op_installed() -> Result<(), SecretError> {
if which::which("op").is_err() {
Err(SecretError::CliNotFound { tool: "op" })
} else {
Ok(())
}
}
+73 -43
View File
@@ -1,3 +1,8 @@
//! CLI integration tests that execute the gman binary.
//!
//! These tests are skipped when cross-compiling because the compiled binary
//! cannot be executed on a different architecture (e.g., ARM64 binary on x86_64 host).
use assert_cmd::prelude::*; use assert_cmd::prelude::*;
use predicates::prelude::*; use predicates::prelude::*;
use std::fs; use std::fs;
@@ -7,6 +12,20 @@ use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use tempfile::TempDir; use tempfile::TempDir;
fn gman_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_gman"))
}
/// Check if the gman binary can be executed on this system.
/// Returns false when cross-compiling (e.g., ARM64 binary on x86_64 host).
fn can_execute_binary() -> bool {
Command::new(gman_bin())
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn setup_env() -> (TempDir, PathBuf, PathBuf) { fn setup_env() -> (TempDir, PathBuf, PathBuf) {
let td = tempfile::tempdir().expect("tempdir"); let td = tempfile::tempdir().expect("tempdir");
let cfg_home = td.path().join("config"); let cfg_home = td.path().join("config");
@@ -46,27 +65,38 @@ providers:
password_file.display() 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.yml"), &cfg).unwrap();
fs::write(app_dir.join("config.yaml"), &cfg).unwrap(); fs::write(app_dir.join("config.yaml"), &cfg).unwrap();
} }
fn create_password_file(path: &Path, content: &[u8]) {
fs::write(path, content).unwrap();
#[cfg(unix)]
{
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
}
}
#[test] #[test]
#[cfg(unix)] #[cfg(unix)]
fn cli_config_no_changes() { fn cli_config_no_changes() {
if !can_execute_binary() {
eprintln!("Skipping test: cannot execute cross-compiled binary");
return;
}
let (td, xdg_cfg, xdg_cache) = setup_env(); let (td, xdg_cfg, xdg_cache) = setup_env();
let pw_file = td.path().join("pw.txt"); let pw_file = td.path().join("pw.txt");
fs::write(&pw_file, b"pw\n").unwrap(); create_password_file(&pw_file, b"pw\n");
write_yaml_config(&xdg_cfg, &pw_file, None); write_yaml_config(&xdg_cfg, &pw_file, None);
// Create a no-op editor script that exits successfully without modifying the file
let editor = td.path().join("noop-editor.sh"); let editor = td.path().join("noop-editor.sh");
fs::write(&editor, b"#!/bin/sh\nexit 0\n").unwrap(); fs::write(&editor, b"#!/bin/sh\nexit 0\n").unwrap();
let mut perms = fs::metadata(&editor).unwrap().permissions(); let mut perms = fs::metadata(&editor).unwrap().permissions();
perms.set_mode(0o755); perms.set_mode(0o755);
fs::set_permissions(&editor, perms).unwrap(); fs::set_permissions(&editor, perms).unwrap();
let mut cmd = Command::cargo_bin("gman").unwrap(); let mut cmd = Command::new(gman_bin());
cmd.env("XDG_CONFIG_HOME", &xdg_cfg) cmd.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
.env("EDITOR", &editor) .env("EDITOR", &editor)
@@ -80,15 +110,23 @@ fn cli_config_no_changes() {
#[test] #[test]
#[cfg(unix)] #[cfg(unix)]
fn cli_config_updates_and_persists() { fn cli_config_updates_and_persists() {
if !can_execute_binary() {
eprintln!("Skipping test: cannot execute cross-compiled binary");
return;
}
let (td, xdg_cfg, xdg_cache) = setup_env(); let (td, xdg_cfg, xdg_cache) = setup_env();
let pw_file = td.path().join("pw.txt"); let pw_file = td.path().join("pw.txt");
fs::write(&pw_file, b"pw\n").unwrap(); create_password_file(&pw_file, b"pw\n");
write_yaml_config(&xdg_cfg, &pw_file, None); write_yaml_config(&xdg_cfg, &pw_file, None);
// Editor script appends a valid run_configs section to the YAML file
let editor = td.path().join("append-run-config.sh"); let editor = td.path().join("append-run-config.sh");
// Note: We need a small sleep to ensure the file modification timestamp changes.
// The dialoguer Editor uses file modification time to detect changes, and on fast
// systems the edit can complete within the same timestamp granularity.
let script = r#"#!/bin/sh let script = r#"#!/bin/sh
FILE="$1" FILE="$1"
sleep 0.1
cat >> "$FILE" <<'EOF' cat >> "$FILE" <<'EOF'
run_configs: run_configs:
- name: echo - name: echo
@@ -101,7 +139,7 @@ exit 0
perms.set_mode(0o755); perms.set_mode(0o755);
fs::set_permissions(&editor, perms).unwrap(); fs::set_permissions(&editor, perms).unwrap();
let mut cmd = Command::cargo_bin("gman").unwrap(); let mut cmd = Command::new(gman_bin());
cmd.env("XDG_CONFIG_HOME", &xdg_cfg) cmd.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
.env("EDITOR", &editor) .env("EDITOR", &editor)
@@ -111,7 +149,6 @@ exit 0
"Configuration updated successfully", "Configuration updated successfully",
)); ));
// Verify that the config file now contains the run_configs key
let cfg_path = xdg_cfg.join("gman").join("config.yml"); let cfg_path = xdg_cfg.join("gman").join("config.yml");
let written = fs::read_to_string(&cfg_path).expect("config file readable"); let written = fs::read_to_string(&cfg_path).expect("config file readable");
assert!(written.contains("run_configs:")); assert!(written.contains("run_configs:"));
@@ -120,8 +157,13 @@ exit 0
#[test] #[test]
fn cli_shows_help() { fn cli_shows_help() {
if !can_execute_binary() {
eprintln!("Skipping test: cannot execute cross-compiled binary");
return;
}
let (_td, cfg, cache) = setup_env(); let (_td, cfg, cache) = setup_env();
let mut cmd = Command::cargo_bin("gman").unwrap(); let mut cmd = Command::new(gman_bin());
cmd.env("XDG_CACHE_HOME", &cache) cmd.env("XDG_CACHE_HOME", &cache)
.env("XDG_CONFIG_HOME", &cfg) .env("XDG_CONFIG_HOME", &cfg)
.arg("--help"); .arg("--help");
@@ -130,27 +172,19 @@ fn cli_shows_help() {
.stdout(predicate::str::contains("Usage").or(predicate::str::contains("Add"))); .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] #[test]
fn cli_add_get_list_update_delete_roundtrip() { fn cli_add_get_list_update_delete_roundtrip() {
if !can_execute_binary() {
eprintln!("Skipping test: cannot execute cross-compiled binary");
return;
}
let (td, xdg_cfg, xdg_cache) = setup_env(); let (td, xdg_cfg, xdg_cache) = setup_env();
let pw_file = td.path().join("pw.txt"); let pw_file = td.path().join("pw.txt");
fs::write(&pw_file, b"testpw\n").unwrap(); create_password_file(&pw_file, b"testpw\n");
write_yaml_config(&xdg_cfg, &pw_file, None); write_yaml_config(&xdg_cfg, &pw_file, None);
// add let mut add = Command::new(gman_bin());
let mut add = Command::cargo_bin("gman").unwrap();
add.env("XDG_CONFIG_HOME", &xdg_cfg) add.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
.stdin(Stdio::piped()) .stdin(Stdio::piped())
@@ -166,8 +200,7 @@ fn cli_add_get_list_update_delete_roundtrip() {
let add_out = child.wait_with_output().unwrap(); let add_out = child.wait_with_output().unwrap();
assert!(add_out.status.success()); assert!(add_out.status.success());
// get (text) let mut get = Command::new(gman_bin());
let mut get = Command::cargo_bin("gman").unwrap();
get.env("XDG_CONFIG_HOME", &xdg_cfg) get.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
.args(["get", "my_api_key"]); .args(["get", "my_api_key"]);
@@ -175,8 +208,7 @@ fn cli_add_get_list_update_delete_roundtrip() {
.success() .success()
.stdout(predicate::str::contains("super_secret")); .stdout(predicate::str::contains("super_secret"));
// get as JSON let mut get_json = Command::new(gman_bin());
let mut get_json = Command::cargo_bin("gman").unwrap();
get_json get_json
.env("XDG_CONFIG_HOME", &xdg_cfg) .env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
@@ -185,8 +217,7 @@ fn cli_add_get_list_update_delete_roundtrip() {
predicate::str::contains("my_api_key").and(predicate::str::contains("super_secret")), predicate::str::contains("my_api_key").and(predicate::str::contains("super_secret")),
); );
// list let mut list = Command::new(gman_bin());
let mut list = Command::cargo_bin("gman").unwrap();
list.env("XDG_CONFIG_HOME", &xdg_cfg) list.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
.arg("list"); .arg("list");
@@ -194,8 +225,7 @@ fn cli_add_get_list_update_delete_roundtrip() {
.success() .success()
.stdout(predicate::str::contains("my_api_key")); .stdout(predicate::str::contains("my_api_key"));
// update let mut update = Command::new(gman_bin());
let mut update = Command::cargo_bin("gman").unwrap();
update update
.env("XDG_CONFIG_HOME", &xdg_cfg) .env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
@@ -211,8 +241,7 @@ fn cli_add_get_list_update_delete_roundtrip() {
let upd_out = child.wait_with_output().unwrap(); let upd_out = child.wait_with_output().unwrap();
assert!(upd_out.status.success()); assert!(upd_out.status.success());
// get again let mut get2 = Command::new(gman_bin());
let mut get2 = Command::cargo_bin("gman").unwrap();
get2.env("XDG_CONFIG_HOME", &xdg_cfg) get2.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
.args(["get", "my_api_key"]); .args(["get", "my_api_key"]);
@@ -220,15 +249,13 @@ fn cli_add_get_list_update_delete_roundtrip() {
.success() .success()
.stdout(predicate::str::contains("new_val")); .stdout(predicate::str::contains("new_val"));
// delete let mut del = Command::new(gman_bin());
let mut del = Command::cargo_bin("gman").unwrap();
del.env("XDG_CONFIG_HOME", &xdg_cfg) del.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
.args(["delete", "my_api_key"]); .args(["delete", "my_api_key"]);
del.assert().success(); del.assert().success();
// get should now fail let mut get_missing = Command::new(gman_bin());
let mut get_missing = Command::cargo_bin("gman").unwrap();
get_missing get_missing
.env("XDG_CONFIG_HOME", &xdg_cfg) .env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
@@ -238,13 +265,17 @@ fn cli_add_get_list_update_delete_roundtrip() {
#[test] #[test]
fn cli_wrap_dry_run_env_injection() { fn cli_wrap_dry_run_env_injection() {
if !can_execute_binary() {
eprintln!("Skipping test: cannot execute cross-compiled binary");
return;
}
let (td, xdg_cfg, xdg_cache) = setup_env(); let (td, xdg_cfg, xdg_cache) = setup_env();
let pw_file = td.path().join("pw.txt"); let pw_file = td.path().join("pw.txt");
fs::write(&pw_file, b"pw\n").unwrap(); create_password_file(&pw_file, b"pw\n");
write_yaml_config(&xdg_cfg, &pw_file, Some("echo")); write_yaml_config(&xdg_cfg, &pw_file, Some("echo"));
// Add the secret so the profile can read it let mut add = Command::new(gman_bin());
let mut add = Command::cargo_bin("gman").unwrap();
add.env("XDG_CONFIG_HOME", &xdg_cfg) add.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
.stdin(Stdio::piped()) .stdin(Stdio::piped())
@@ -255,8 +286,7 @@ fn cli_wrap_dry_run_env_injection() {
let add_out = child.wait_with_output().unwrap(); let add_out = child.wait_with_output().unwrap();
assert!(add_out.status.success()); assert!(add_out.status.success());
// Dry-run wrapping: prints preview command let mut wrap = Command::new(gman_bin());
let mut wrap = Command::cargo_bin("gman").unwrap();
wrap.env("XDG_CONFIG_HOME", &xdg_cfg) wrap.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
.arg("--dry-run") .arg("--dry-run")
+19 -10
View File
@@ -9,6 +9,7 @@ mod tests {
fn test_run_config_valid() { fn test_run_config_valid() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -23,6 +24,7 @@ mod tests {
fn test_run_config_missing_name() { fn test_run_config_missing_name() {
let run_config = RunConfig { let run_config = RunConfig {
name: None, name: None,
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -37,6 +39,7 @@ mod tests {
fn test_run_config_missing_secrets() { fn test_run_config_missing_secrets() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: None, secrets: None,
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -51,6 +54,7 @@ mod tests {
fn test_run_config_invalid_flag_position() { fn test_run_config_invalid_flag_position() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: Some(0), flag_position: Some(0),
@@ -65,6 +69,7 @@ mod tests {
fn test_run_config_flags_or_none_all_some() { fn test_run_config_flags_or_none_all_some() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: Some(1), flag_position: Some(1),
@@ -79,6 +84,7 @@ mod tests {
fn test_run_config_flags_or_none_all_none() { fn test_run_config_flags_or_none_all_none() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -93,6 +99,7 @@ mod tests {
fn test_run_config_flags_or_none_partial_some() { fn test_run_config_flags_or_none_partial_some() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: None, flag_position: None,
@@ -107,6 +114,7 @@ mod tests {
fn test_run_config_flags_or_none_missing_placeholder() { fn test_run_config_flags_or_none_missing_placeholder() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: Some(1), flag_position: Some(1),
@@ -121,6 +129,7 @@ mod tests {
fn test_run_config_flags_or_files_all_none() { fn test_run_config_flags_or_files_all_none() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -135,6 +144,7 @@ mod tests {
fn test_run_config_flags_or_files_files_is_some() { fn test_run_config_flags_or_files_files_is_some() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -149,6 +159,7 @@ mod tests {
fn test_run_config_flags_or_files_all_some() { fn test_run_config_flags_or_files_all_some() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
provider: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: Some(1), flag_position: Some(1),
@@ -241,16 +252,14 @@ mod tests {
#[test] #[test]
fn test_config_local_provider_password_file() { fn test_config_local_provider_password_file() {
let path = Config::local_provider_password_file(); let path = Config::local_provider_password_file();
let expected_path = dirs::home_dir().map(|p| p.join(".gman_password")); // Derive expected filename based on current test executable name
if let Some(p) = &expected_path { let exe = std::env::current_exe().expect("current_exe");
if !p.exists() { let stem = exe
assert_eq!(path, None); .file_stem()
} else { .and_then(|s| s.to_str())
assert_eq!(path, expected_path); .expect("utf-8 file stem");
} let expected = dirs::home_dir().map(|p| p.join(format!(".{}_password", stem)));
} else { assert_eq!(Some(path), expected);
assert_eq!(path, None);
}
} }
#[test] #[test]
+8
View File
@@ -0,0 +1,8 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 155469a45d7311cd4003e23a3bcdaa8e55879e6222c1b6313a2b1f0b563bb195 # shrinks to password = "", msg = " "
cc 0bc9f608677234c082d10ff51b15dc39b4c194cdf920b4d87e553467c93824ed # shrinks to password = "", msg = ""
+6 -7
View File
@@ -1,15 +1,15 @@
use base64::Engine; use base64::Engine;
use gman::{decrypt_string, encrypt_string}; use gman::{decrypt_string, encrypt_string};
use proptest::prelude::*; use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
}
use secrecy::SecretString; use secrecy::SecretString;
proptest! { proptest! {
// Reduced case count because Argon2 key derivation is intentionally slow
// (65 MiB memory, 3 iterations per encryption/decryption)
#![proptest_config(ProptestConfig::with_cases(4))]
#[test] #[test]
fn prop_encrypt_decrypt_roundtrip(password in ".{0,64}", msg in ".{0,512}") { fn prop_encrypt_decrypt_roundtrip(password in ".{1,64}", msg in ".{0,512}") {
let pw = SecretString::new(password.into()); let pw = SecretString::new(password.into());
let env = encrypt_string(pw.clone(), &msg).unwrap(); let env = encrypt_string(pw.clone(), &msg).unwrap();
let out = decrypt_string(pw, &env).unwrap(); let out = decrypt_string(pw, &env).unwrap();
@@ -18,10 +18,9 @@ proptest! {
} }
#[test] #[test]
fn prop_tamper_ciphertext_detected(password in ".{0,32}", msg in ".{1,128}") { fn prop_tamper_ciphertext_detected(password in ".{1,32}", msg in ".{1,128}") {
let pw = SecretString::new(password.into()); let pw = SecretString::new(password.into());
let env = encrypt_string(pw.clone(), &msg).unwrap(); 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 mut parts: Vec<&str> = env.split(';').collect();
let ct_b64 = parts[6].strip_prefix("ct=").unwrap(); let ct_b64 = parts[6].strip_prefix("ct=").unwrap();
let mut ct = base64::engine::general_purpose::STANDARD.decode(ct_b64).unwrap(); let mut ct = base64::engine::general_purpose::STANDARD.decode(ct_b64).unwrap();
+53
View File
@@ -0,0 +1,53 @@
use gman::config::{Config, ProviderConfig};
use gman::providers::{SecretProvider, SupportedProvider};
use pretty_assertions::{assert_eq, assert_str_eq};
use validator::Validate;
#[test]
fn test_gopass_supported_provider_display_and_validate_from_yaml() {
// Build a SupportedProvider via YAML to avoid direct type import
let yaml = r#"---
type: gopass
store: personal
"#;
let sp: SupportedProvider = serde_yaml::from_str(yaml).expect("valid supported provider yaml");
// Validate delegates to inner provider (no required fields)
assert!(sp.validate().is_ok());
// Display formatting for the enum variant
assert_eq!(sp.to_string(), "gopass");
}
#[test]
fn test_provider_config_with_gopass_deserialize_and_extract() {
// Minimal ProviderConfig YAML using the gopass variant
let yaml = r#"---
name: gopass
type: gopass
"#;
let pc: ProviderConfig = serde_yaml::from_str(yaml).expect("valid provider config yaml");
// Gopass has no required fields, so validation should pass
assert!(pc.validate().is_ok());
// Extract the provider and inspect its name via the trait
let mut pc_owned = pc.clone();
let provider: &mut dyn SecretProvider = pc_owned.extract_provider();
assert_str_eq!(provider.name(), "GopassProvider");
// Round-trip through Config with default_provider
let cfg_yaml = r#"---
default_provider: gopass
providers:
- name: gopass
type: gopass
store: personal
"#;
let cfg: Config = serde_yaml::from_str(cfg_yaml).expect("valid config yaml");
assert!(cfg.validate().is_ok());
let extracted = cfg
.extract_provider_config(None)
.expect("should find default provider");
assert_eq!(extracted.name.as_deref(), Some("gopass"));
}
+5 -4
View File
@@ -58,10 +58,11 @@ fn test_local_provider_invalid_email() {
#[test] #[test]
fn test_local_provider_default() { fn test_local_provider_default() {
let provider = LocalProvider::default(); let provider = LocalProvider::default();
assert_eq!( let expected_pw = {
provider.password_file, let p = Config::local_provider_password_file();
Config::local_provider_password_file() if p.exists() { Some(p) } else { None }
); };
assert_eq!(provider.password_file, expected_pw);
assert_eq!(provider.git_branch, Some("main".into())); assert_eq!(provider.git_branch, Some("main".into()));
assert_eq!(provider.git_remote_url, None); assert_eq!(provider.git_remote_url, None);
assert_eq!(provider.git_user_name, None); assert_eq!(provider.git_user_name, None);
+2
View File
@@ -1,5 +1,7 @@
mod aws_secrets_manager_tests; mod aws_secrets_manager_tests;
mod azure_key_vault_tests; mod azure_key_vault_tests;
mod gcp_secret_manager_tests; mod gcp_secret_manager_tests;
mod gopass_tests;
mod local_tests; mod local_tests;
mod one_password_tests;
mod provider_tests; mod provider_tests;
+113
View File
@@ -0,0 +1,113 @@
use gman::config::{Config, ProviderConfig};
use gman::providers::{SecretProvider, SupportedProvider};
use pretty_assertions::{assert_eq, assert_str_eq};
use validator::Validate;
#[test]
fn test_one_password_supported_provider_display_and_validate_from_yaml() {
let yaml = r#"---
type: one_password
vault: Production
account: my.1password.com
"#;
let sp: SupportedProvider = serde_yaml::from_str(yaml).expect("valid supported provider yaml");
assert!(sp.validate().is_ok());
assert_eq!(sp.to_string(), "one_password");
}
#[test]
fn test_one_password_supported_provider_minimal_yaml() {
let yaml = r#"---
type: one_password
"#;
let sp: SupportedProvider = serde_yaml::from_str(yaml).expect("valid supported provider yaml");
assert!(sp.validate().is_ok());
assert_eq!(sp.to_string(), "one_password");
}
#[test]
fn test_one_password_supported_provider_vault_only() {
let yaml = r#"---
type: one_password
vault: Personal
"#;
let sp: SupportedProvider = serde_yaml::from_str(yaml).expect("valid supported provider yaml");
assert!(sp.validate().is_ok());
}
#[test]
fn test_one_password_supported_provider_account_only() {
let yaml = r#"---
type: one_password
account: team.1password.com
"#;
let sp: SupportedProvider = serde_yaml::from_str(yaml).expect("valid supported provider yaml");
assert!(sp.validate().is_ok());
}
#[test]
fn test_one_password_supported_provider_rejects_unknown_fields() {
let yaml = r#"---
type: one_password
vault: Production
unknown_field: bad
"#;
let result: Result<SupportedProvider, _> = serde_yaml::from_str(yaml);
assert!(result.is_err());
}
#[test]
fn test_provider_config_with_one_password_deserialize_and_extract() {
let yaml = r#"---
name: op
type: one_password
"#;
let pc: ProviderConfig = serde_yaml::from_str(yaml).expect("valid provider config yaml");
assert!(pc.validate().is_ok());
let mut pc_owned = pc.clone();
let provider: &mut dyn SecretProvider = pc_owned.extract_provider();
assert_str_eq!(provider.name(), "OnePasswordProvider");
let cfg_yaml = r#"---
default_provider: op
providers:
- name: op
type: one_password
vault: Production
account: my.1password.com
"#;
let cfg: Config = serde_yaml::from_str(cfg_yaml).expect("valid config yaml");
assert!(cfg.validate().is_ok());
let extracted = cfg
.extract_provider_config(None)
.expect("should find default provider");
assert_eq!(extracted.name.as_deref(), Some("op"));
}
#[test]
fn test_one_password_config_with_multiple_providers() {
let cfg_yaml = r#"---
default_provider: local
providers:
- name: local
type: local
- name: op
type: one_password
vault: Production
"#;
let cfg: Config = serde_yaml::from_str(cfg_yaml).expect("valid config yaml");
assert!(cfg.validate().is_ok());
let extracted = cfg
.extract_provider_config(Some("op".into()))
.expect("should find op provider");
assert_eq!(extracted.name.as_deref(), Some("op"));
}