13 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
22 changed files with 1984 additions and 1188 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
+13
View File
@@ -5,6 +5,19 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 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
+9 -1
View File
@@ -48,7 +48,8 @@ cz commit
1. Clone this repo
2. Run `cargo test` to set up hooks
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
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
@@ -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
```
## 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!
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!
Generated
+677 -580
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "gman"
version = "0.3.0"
version = "0.4.1"
edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "Universal command line secret management and injection tool"
@@ -58,16 +58,17 @@ tokio = { version = "1.47.1", features = ["full"] }
aws-config = { version = "1.8.12", features = ["behavior-version-latest"] }
async-trait = "0.1.89"
futures = "0.3.31"
gcloud-sdk = { version = "0.28.1", features = [
gcloud-sdk = { version = "0.28.5", features = [
"google-cloud-secretmanager-v1",
] }
crc32c = "0.6.8"
azure_core = "0.31.0"
azure_identity = "0.31.0"
azure_security_keyvault_secrets = "0.10.0"
aws-lc-sys = { version = "0.37.0", features = ["bindgen"] }
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]
openssl = { version = "0.10", features = ["vendored"] }
-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}
+52 -16
View File
@@ -2,7 +2,6 @@
![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)
![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)
![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)
@@ -14,8 +13,8 @@ files or sprinkling environment variables everywhere.
## Overview
`gman` acts as a universal wrapper for any command that needs credentials. Store your secretsAPI tokens, passwords,
certswith a provider, then either fetch them directly or run your command through `gman` to inject what it needs as
`gman` acts as a universal wrapper for any command that needs credentials. Store your secrets (e.g. API tokens, passwords,
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.
## Quick Examples: Before vs After
@@ -96,6 +95,7 @@ gman aws sts get-caller-identity
- [GCP Secret Manager](#provider-gcp_secret_manager)
- [Azure Key Vault](#provider-azure_key_vault)
- [Gopass](#provider-gopass)
- [1Password](#provider-one_password)
- [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)
@@ -279,18 +279,18 @@ documented and added without breaking existing setups. The following table shows
| 🚫 | Won't Add |
| Provider Name | Status | Configuration Docs | Comments |
|--------------------------------------------------------------------------------------------------------------------------|--------|------------------------------------------------------|--------------------------------------------|
| `local` | ✅ | [Local](#provider-local) | |
| [`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) | 🕒 | | |
| [`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) | |
| [`gopass`](https://www.gopass.pw/) | ✅ | | |
| [`1password`](https://1password.com/) | 🕒 | | |
| [`bitwarden`](https://bitwarden.com/) | 🕒 | | |
| [`dashlane`](https://www.dashlane.com/) | 🕒 | | Waiting for CLI support for adding secrets |
| [`lastpass`](https://www.lastpass.com/) | 🕒 | | |
| Provider Name | Status | Configuration Docs | Comments |
|-------------------------------------------------------------------------------------------------|--------|------------------------------------------------------|--------------------------------------------|
| `local` | ✅ | [Local](#provider-local) | |
| [`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) | 🕒 | | |
| [`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) | |
| [`gopass`](https://www.gopass.pw/) | ✅ | [Gopass](#provider-gopass) | |
| [`1password`](https://1password.com/) | ✅ | [1Password](#provider-one_password) | |
| [`bitwarden`](https://bitwarden.com/) | 🕒 | | |
| [`dashlane`](https://www.dashlane.com/) | 🕒 | | Waiting for CLI support for adding secrets |
| [`lastpass`](https://www.lastpass.com/) | 🕒 | | |
### Provider: `local`
@@ -450,6 +450,42 @@ Important notes:
- 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 (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are
@@ -657,7 +693,7 @@ gman managarr
### 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`.
+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 { "" } }}
-49
View File
@@ -116,12 +116,6 @@ enum Commands {
/// Sync secrets with remote storage (if supported by the provider)
Sync {},
// TODO: Remove once all users have migrated their local vaults
/// Migrate local vault secrets to the current secure encryption format.
/// This is only needed if you have secrets encrypted with older versions of gman.
/// Only works with the local provider.
Migrate {},
/// Open and edit the config file in the default text editor
Config {},
@@ -264,49 +258,6 @@ async fn main() -> Result<()> {
}
})?;
}
// TODO: Remove once all users have migrated their local vaults
Commands::Migrate {} => {
use gman::providers::SupportedProvider;
use gman::providers::local::LocalProvider;
let provider_config_for_migrate =
config.extract_provider_config(cli.provider.clone())?;
let local_provider: LocalProvider = match provider_config_for_migrate.provider_type {
SupportedProvider::Local { provider_def } => provider_def,
_ => {
anyhow::bail!("The migrate command only works with the local provider.");
}
};
println!("Migrating vault secrets to current secure format...");
let result = local_provider.migrate_vault().await?;
if result.total == 0 {
println!("Vault is empty, nothing to migrate.");
} else {
println!(
"Migration complete: {} total, {} migrated, {} already current",
result.total, result.migrated, result.already_current
);
if !result.failed.is_empty() {
eprintln!("\n⚠ Failed to migrate {} secret(s):", result.failed.len());
for (key, error) in &result.failed {
eprintln!(" - {}: {}", key, error);
}
}
if result.migrated > 0 {
println!(
"\n✓ Successfully migrated {} secret(s) to the secure format.",
result.migrated
);
} else if result.failed.is_empty() {
println!("\n✓ All secrets are already using the current secure format.");
}
}
}
Commands::External(tokens) => {
wrap_and_run_command(cli.provider, &config, tokens, cli.profile, cli.dry_run).await?;
}
+4
View File
@@ -169,6 +169,10 @@ impl ProviderConfig {
debug!("Using Gopass provider");
provider_def
}
SupportedProvider::OnePassword { provider_def } => {
debug!("Using 1Password provider");
provider_def
}
}
}
}
+2
View File
@@ -36,6 +36,8 @@ pub mod config;
/// Secret provider trait and implementations.
pub mod providers;
pub use providers::{SecretError, SyncError};
pub(crate) const HEADER: &str = "$VAULT";
pub(crate) const VERSION: &str = "v1";
pub(crate) const KDF: &str = "argon2id";
+39 -29
View File
@@ -1,12 +1,14 @@
use crate::providers::SecretProvider;
use anyhow::Context;
use anyhow::Result;
use aws_config::Region;
use aws_sdk_secretsmanager::Client;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use validator::Validate;
use crate::providers::error::{SecretError, classify_aws_error};
use crate::providers::SecretProvider;
const PROVIDER: &str = "aws_secrets_manager";
#[skip_serializing_none]
/// Configuration for AWS Secrets Manager provider
/// See [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/)
@@ -43,18 +45,21 @@ impl SecretProvider for AwsSecretsManagerProvider {
"AwsSecretsManagerProvider"
}
async fn get_secret(&self, key: &str) -> Result<String> {
self.get_client()
.await?
async fn get_secret(&self, key: &str) -> Result<String, SecretError> {
let client = self.get_client().await?;
let resp = client
.get_secret_value()
.secret_id(key)
.send()
.await?
.secret_string
.with_context(|| format!("Secret '{key}' not found"))
.await
.map_err(|e| classify_aws_error(e.into(), Some(key), "get_secret"))?;
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()
.await?
.create_secret()
@@ -62,12 +67,12 @@ impl SecretProvider for AwsSecretsManagerProvider {
.secret_string(value)
.send()
.await
.with_context(|| format!("Failed to set secret '{key}'"))?;
.map_err(|e| classify_aws_error(e.into(), Some(key), "set_secret"))?;
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()
.await?
.update_secret()
@@ -75,12 +80,12 @@ impl SecretProvider for AwsSecretsManagerProvider {
.secret_string(value)
.send()
.await
.with_context(|| format!("Failed to update secret '{key}'"))?;
.map_err(|e| classify_aws_error(e.into(), Some(key), "update_secret"))?;
Ok(())
}
async fn delete_secret(&self, key: &str) -> Result<()> {
async fn delete_secret(&self, key: &str) -> Result<(), SecretError> {
self.get_client()
.await?
.delete_secret()
@@ -88,32 +93,37 @@ impl SecretProvider for AwsSecretsManagerProvider {
.force_delete_without_recovery(true)
.send()
.await
.with_context(|| format!("Failed to delete secret '{key}'"))?;
.map_err(|e| classify_aws_error(e.into(), Some(key), "delete_secret"))?;
Ok(())
}
async fn list_secrets(&self) -> Result<Vec<String>> {
self.get_client()
async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
let resp = self
.get_client()
.await?
.list_secrets()
.send()
.await?
.await
.map_err(|e| classify_aws_error(e.into(), None, "list_secrets"))?;
Ok(resp
.secret_list
.with_context(|| "No secrets found")
.map(|secrets| secrets.into_iter().filter_map(|s| s.name).collect())
.unwrap_or_default()
.into_iter()
.filter_map(|s| s.name)
.collect())
}
}
impl AwsSecretsManagerProvider {
async fn get_client(&self) -> Result<Client> {
let region = self
.aws_region
.clone()
.with_context(|| "aws_region is required")?;
let profile = self
.aws_profile
.clone()
.with_context(|| "aws_profile is required")?;
async fn get_client(&self) -> Result<Client, SecretError> {
let region = self.aws_region.clone().ok_or_else(|| SecretError::Config {
provider: PROVIDER,
message: "aws_region is required".to_string(),
})?;
let profile = self.aws_profile.clone().ok_or_else(|| SecretError::Config {
provider: PROVIDER,
message: "aws_profile is required".to_string(),
})?;
let config = aws_config::from_env()
.region(Region::new(region))
+66 -27
View File
@@ -1,5 +1,5 @@
use crate::providers::SecretProvider;
use anyhow::{Context, Result};
use std::sync::Arc;
use azure_core::credentials::TokenCredential;
use azure_identity::DeveloperToolsCredential;
use azure_security_keyvault_secrets::models::SetSecretParameters;
@@ -7,9 +7,13 @@ use azure_security_keyvault_secrets::{ResourceExt, SecretClient};
use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use std::sync::Arc;
use validator::Validate;
use crate::providers::SecretProvider;
use crate::providers::error::{SecretError, classify_azure_error};
const PROVIDER: &str = "azure_key_vault";
#[skip_serializing_none]
/// Configuration for Azure Key Vault provider
/// See [Azure Key Vault](https://azure.microsoft.com/en-us/services/key-vault/)
@@ -41,43 +45,70 @@ impl SecretProvider for AzureKeyVaultProvider {
"AzureKeyVaultProvider"
}
async fn get_secret(&self, key: &str) -> Result<String> {
let response = self.get_client()?.get_secret(key, None).await?;
let body = response.into_model()?;
async fn get_secret(&self, key: &str) -> Result<String, SecretError> {
let response = self
.get_client()?
.get_secret(key, None)
.await
.map_err(|e| classify_azure_error(e.into(), Some(key), "get_secret"))?;
let body = response
.into_model()
.map_err(|e| SecretError::Other(e.into()))?;
body.value
.with_context(|| format!("Secret '{}' not found", key))
body.value.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> {
let params = SetSecretParameters {
value: Some(value.to_string()),
..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()?
.set_secret(key, params.try_into()?, None)
.await?
.into_model()?;
.set_secret(key, body, None)
.await
.map_err(|e| classify_azure_error(e.into(), Some(key), "set_secret"))?
.into_model()
.map_err(|e| SecretError::Other(e.into()))?;
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
}
async fn delete_secret(&self, key: &str) -> Result<()> {
self.get_client()?.delete_secret(key, None).await?;
async fn delete_secret(&self, key: &str) -> Result<(), SecretError> {
self.get_client()?
.delete_secret(key, None)
.await
.map_err(|e| classify_azure_error(e.into(), Some(key), "delete_secret"))?;
Ok(())
}
async fn list_secrets(&self) -> Result<Vec<String>> {
let mut pager = self.get_client()?.list_secret_properties(None)?;
async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
let mut pager = self
.get_client()?
.list_secret_properties(None)
.map_err(|e| classify_azure_error(e.into(), None, "list_secrets"))?;
let mut secrets = Vec::new();
while let Some(props) = pager.try_next().await? {
let name = props.resource_id()?.name;
while let Some(props) = pager
.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);
}
@@ -86,17 +117,25 @@ impl SecretProvider for AzureKeyVaultProvider {
}
impl AzureKeyVaultProvider {
fn get_client(&self) -> Result<SecretClient> {
let credential: Arc<dyn TokenCredential> = DeveloperToolsCredential::new(None)?;
fn get_client(&self) -> Result<SecretClient, SecretError> {
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(
format!(
"https://{}.vault.azure.net",
self.vault_name.as_ref().unwrap()
)
.as_str(),
format!("https://{}.vault.azure.net", vault_name).as_str(),
credential,
None,
)?;
)
.map_err(|e| SecretError::Config {
provider: PROVIDER,
message: format!("failed to create Azure Key Vault client: {}", e),
})?;
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::{Context, Result, anyhow};
use anyhow::anyhow;
use gcloud_sdk::google::cloud::secretmanager::v1;
use gcloud_sdk::google::cloud::secretmanager::v1::replication::Automatic;
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 validator::Validate;
use crate::providers::SecretProvider;
use crate::providers::error::{SecretError, classify_gcp_error};
type SecretsManagerClient = GoogleApi<SecretManagerServiceClient<GoogleAuthMiddleware>>;
const PROVIDER: &str = "gcp_secret_manager";
#[skip_serializing_none]
/// Configuration for GCP Secret Manager provider
/// See [GCP Secret Manager](https://cloud.google.com/secret-manager)
@@ -48,8 +52,8 @@ impl SecretProvider for GcpSecretManagerProvider {
"GcpSecretManagerProvider"
}
async fn get_secret(&self, key: &str) -> Result<String> {
let secret_value = self
async fn get_secret(&self, key: &str) -> Result<String, SecretError> {
let response = self
.get_client()
.await?
.get()
@@ -60,20 +64,22 @@ impl SecretProvider for GcpSecretManagerProvider {
key
),
})
.await?
.into_inner()
.payload
.ok_or_else(|| anyhow!("Secret '{}' not found", key))?
.data
.ref_sensitive_value()
.to_vec();
let secret_string = String::from_utf8(secret_value)
.with_context(|| format!("Invalid UTF-8 in secret '{})'", key))?;
.await
.map_err(|e| classify_gcp_error(e.into(), Some(key), "get_secret"))?
.into_inner();
let payload = response.payload.ok_or_else(|| SecretError::NotFound {
key: key.to_string(),
provider: PROVIDER,
})?;
let secret_value = payload.data.ref_sensitive_value().to_vec();
let secret_string = String::from_utf8(secret_value).map_err(|_| {
SecretError::Other(anyhow!("secret value is not valid UTF-8"))
})?;
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 secret_name = format!("{}/secrets/{}", parent, key);
let secret = Secret {
@@ -96,9 +102,12 @@ impl SecretProvider for GcpSecretManagerProvider {
.await
.map_err(|e| {
if e.code() == Code::AlreadyExists {
anyhow!("Secret already exists")
SecretError::AlreadyExists {
key: key.to_string(),
provider: PROVIDER,
}
} else {
e.into()
classify_gcp_error(e.into(), Some(key), "set_secret")
}
})?;
@@ -113,12 +122,13 @@ impl SecretProvider for GcpSecretManagerProvider {
data_crc32c: Some(crc32c),
}),
})
.await?;
.await
.map_err(|e| classify_gcp_error(e.into(), Some(key), "set_secret"))?;
Ok(())
}
async fn delete_secret(&self, key: &str) -> Result<()> {
async fn delete_secret(&self, key: &str) -> Result<(), SecretError> {
let name = format!(
"projects/{}/secrets/{}",
self.gcp_project_id.as_ref().unwrap(),
@@ -131,11 +141,12 @@ impl SecretProvider for GcpSecretManagerProvider {
name,
etag: "".to_string(),
})
.await?;
.await
.map_err(|e| classify_gcp_error(e.into(), Some(key), "delete_secret"))?;
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!(
"projects/{}/secrets/{}",
self.gcp_project_id.as_ref().unwrap(),
@@ -154,12 +165,13 @@ impl SecretProvider for GcpSecretManagerProvider {
data_crc32c: Some(crc32c),
}),
})
.await?;
.await
.map_err(|e| classify_gcp_error(e.into(), Some(key), "update_secret"))?;
Ok(())
}
async fn list_secrets(&self) -> Result<Vec<String>> {
async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
let request = ListSecretsRequest {
parent: format!("projects/{}", self.gcp_project_id.as_ref().unwrap()),
..Default::default()
@@ -169,7 +181,8 @@ impl SecretProvider for GcpSecretManagerProvider {
.await?
.get()
.list_secrets(request)
.await?
.await
.map_err(|e| classify_gcp_error(e.into(), None, "list_secrets"))?
.into_inner()
.secrets
.iter()
@@ -188,13 +201,17 @@ impl SecretProvider for GcpSecretManagerProvider {
}
impl GcpSecretManagerProvider {
async fn get_client(&self) -> Result<SecretsManagerClient> {
async fn get_client(&self) -> Result<SecretsManagerClient, SecretError> {
let client = GoogleApi::from_function(
SecretManagerServiceClient::new,
"https://secretmanager.googleapis.com",
None,
)
.await?;
.await
.map_err(|e| SecretError::AuthFailed {
provider: PROVIDER,
source: e.into(),
})?;
Ok(client)
}
+187 -100
View File
@@ -1,15 +1,51 @@
use crate::calling_app_name;
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 dialoguer::Confirm;
use dialoguer::theme::ColorfulTheme;
use indoc::formatdoc;
use log::debug;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::{env, fs};
use thiserror::Error;
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)]
pub struct SyncOpts<'a> {
#[validate(required)]
@@ -21,38 +57,36 @@ pub struct SyncOpts<'a> {
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);
opts.validate()
.with_context(|| "invalid git sync options")?;
opts.validate().map_err(|e| SyncError::Config {
message: format!("invalid git sync options: {}", e),
})?;
let commit_message = format!("chore: sync @ {}", Utc::now().to_rfc3339());
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()
.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 repo_name = repo_name_from_url(remote_url);
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")
.with_context(|| "get default vault path")?;
.map_err(|e| SyncError::Config {
message: format!("get default vault path: {}", e),
})?;
let repo_vault = repo_dir.join("vault.yml");
if default_vault.exists() && !repo_vault.exists() {
fs::rename(&default_vault, &repo_vault).with_context(|| {
format!(
"move {} -> {}",
default_vault.display(),
repo_vault.display()
)
})?;
fs::rename(&default_vault, &repo_vault)?;
} else if !repo_vault.exists() {
// Ensure an empty vault exists to allow initial commits
fs::write(&repo_vault, "{}\n")
.with_context(|| format!("create {}", repo_vault.display()))?;
fs::write(&repo_vault, "{}\n")?;
}
let git = resolve_git(opts.git_executable.as_ref())?;
@@ -88,48 +122,43 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
checkout_branch(&git, &repo_dir, branch)?;
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)?;
// 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)?;
commit_now(&git, &repo_dir, &commit_message)?;
run_git(
&git,
&repo_dir,
&["push", "-u", "origin", "--force", branch],
)?;
run_git_push(&git, &repo_dir, branch)?;
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");
if let Some(name) = name {
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");
if let Some(email) = email {
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");
if let Some(p) = override_path {
return Ok(p.to_path_buf());
@@ -140,63 +169,116 @@ pub(in crate::providers) fn resolve_git(override_path: Option<&PathBuf>) -> Resu
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");
run_git_config_capture(git, &["config", "user.name"])
.with_context(|| "unable to determine git user name")
run_git_config_capture(git, &["config", "user.name"]).map_err(|e| SyncError::Config {
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");
run_git_config_capture(git, &["config", "user.email"])
.with_context(|| "unable to determine git user email")
run_git_config_capture(git, &["config", "user.email"]).map_err(|e| SyncError::Config {
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)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.context("run git --version")?
.map_err(|_| SyncError::GitNotFound)?
.success();
if !ok {
Err(anyhow!("`git` not available on PATH"))
Err(SyncError::GitNotFound)
} else {
Ok(())
}
}
fn run_git(git: &Path, repo: &Path, args: &[&str]) -> Result<()> {
let status = Command::new(git)
fn run_git(git: &Path, repo: &Path, args: &[&str]) -> SyncResult<()> {
let out = Command::new(git)
.arg("-C")
.arg(repo)
.args(args)
.status()
.with_context(|| format!("git {}", args.join(" ")))?;
if !status.success() {
return Err(anyhow!("git failed: {}", args.join(" ")));
.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(())
}
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)
.args(args)
.output()
.with_context(|| format!("git {}", args.join(" ")))?;
.arg("-C")
.arg(repo)
.args(["push", "-u", "origin", "--force", branch])
.output()?;
if !out.status.success() {
return Err(anyhow!(
"git failed (exit {}): {}",
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr)
));
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
let lc = stderr.to_lowercase();
let source = anyhow!("git push 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_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())
}
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)
.arg("-C")
.arg(repo)
@@ -223,19 +305,28 @@ fn init_repo_if_needed(git: &Path, repo: &Path, branch: &str) -> Result<()> {
Ok(())
}
fn set_local_identity(git: &Path, repo: &Path, username: String, email: String) -> Result<()> {
run_git(git, repo, &["config", "user.name", &username])?;
run_git(git, repo, &["config", "user.email", &email])?;
fn set_local_identity(
git: &Path,
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(())
}
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])?;
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)
.arg("-C")
.arg(repo)
@@ -249,49 +340,48 @@ fn set_origin(git: &Path, repo: &Path, url: &str) -> Result<()> {
if has_origin {
run_git(git, repo, &["remote", "set-url", "origin", url])?;
} 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?"))
.default(false)
.interact()?
{
run_git(git, repo, &["remote", "add", "origin", url])?;
} else {
return Err(anyhow!("Remote origin does not yet exist. Please create remote origin before synchronizing, then try again"));
}
.with_prompt(format!(
"Have you already created the remote origin '{url}' on the Git host so we can push to it?"
))
.default(false)
.interact()
.map_err(|e| SyncError::Config {
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(())
}
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"])?;
Ok(())
}
fn fetch_and_pull(git: &Path, repo: &Path, branch: &str) -> Result<()> {
// Fetch all refs from origin (safe even if branch doesn't exist remotely)
run_git(git, repo, &["fetch", "origin", "--prune"])
.with_context(|| "Failed to fetch changes from remote")?;
fn fetch_and_pull(git: &Path, repo: &Path, branch: &str) -> SyncResult<()> {
run_git_fetch(git, repo)?;
let origin_ref = format!("origin/{branch}");
let remote_has_branch = has_remote_branch(git, repo, branch);
// If the repo has no commits yet, prefer remote state and discard local
// if the remote branch exists. Otherwise, keep local state and allow an
// initial commit to be created and pushed.
if !has_head(git, repo) {
if remote_has_branch {
run_git(git, repo, &["checkout", "-f", "-B", branch, &origin_ref])
.with_context(|| "Failed to checkout remote branch over local state")?;
run_git(git, repo, &["reset", "--hard", &origin_ref])
.with_context(|| "Failed to hard reset to remote branch")?;
run_git(git, repo, &["clean", "-fd"])
.with_context(|| "Failed to clean untracked files")?;
run_git(git, repo, &["checkout", "-f", "-B", branch, &origin_ref])?;
run_git(git, repo, &["reset", "--hard", &origin_ref])?;
run_git(git, repo, &["clean", "-fd"])?;
}
return Ok(());
}
// If we have local history and the remote branch exists, fast-forward.
if remote_has_branch {
run_git(git, repo, &["merge", "--ff-only", &origin_ref])
.with_context(|| "Failed to merge remote changes")?;
run_git(git, repo, &["merge", "--ff-only", &origin_ref])?;
}
Ok(())
}
@@ -325,13 +415,12 @@ fn has_head(git: &Path, repo: &Path) -> bool {
.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)
.arg("-C")
.arg(repo)
.args(["diff", "--cached", "--quiet", "--exit-code"])
.status()
.context("git diff --cached")?
.status()?
.code()
.map(|c| c == 1)
.unwrap_or(false);
@@ -399,12 +488,10 @@ mod tests {
#[test]
fn resolve_git_prefers_override_and_env() {
// Override path wins
let override_path = Some(PathBuf::from("/custom/git"));
let got = resolve_git(override_path.as_ref()).unwrap();
assert_eq!(got, PathBuf::from("/custom/git"));
// If no override, env var is used
unsafe {
env::set_var("GIT_EXECUTABLE", "/env/git");
}
+76 -44
View File
@@ -1,11 +1,24 @@
use crate::providers::{ENV_PATH, SecretProvider};
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
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.
@@ -37,7 +50,7 @@ impl SecretProvider for GopassProvider {
"GopassProvider"
}
async fn get_secret(&self, key: &str) -> Result<String> {
async fn get_secret(&self, key: &str) -> Result<String, SecretError> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass")
@@ -47,25 +60,27 @@ impl SecretProvider for GopassProvider {
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.context("Failed to spawn gopass command")?;
.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)
.context("Failed to read gopass output")?;
.read_to_string(&mut output)?;
let status = child.wait().context("Failed to wait on gopass process")?;
let status = child.wait()?;
if !status.success() {
return Err(anyhow!("gopass command failed with status: {}", status));
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<()> {
async fn set_secret(&self, key: &str, value: &str) -> Result<(), SecretError> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass")
@@ -73,32 +88,41 @@ impl SecretProvider for GopassProvider {
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn gopass command")?;
.map_err(map_spawn_err)?;
{
let stdin = child.stdin.as_mut().expect("Failed to open gopass stdin");
stdin
.write_all(value.as_bytes())
.context("Failed to write to gopass stdin")?;
stdin.write_all(value.as_bytes())?;
}
let status = child.wait().context("Failed to wait on gopass process")?;
if !status.success() {
return Err(anyhow!("gopass command failed with status: {}", status));
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<()> {
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<()> {
async fn delete_secret(&self, key: &str) -> Result<(), SecretError> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass")
@@ -108,17 +132,20 @@ impl SecretProvider for GopassProvider {
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.context("Failed to spawn gopass command")?;
.map_err(map_spawn_err)?;
let status = child.wait().context("Failed to wait on gopass process")?;
let status = child.wait()?;
if !status.success() {
return Err(anyhow!("gopass command failed with status: {}", status));
return Err(SecretError::NotFound {
key: key.to_string(),
provider: PROVIDER,
});
}
Ok(())
}
async fn list_secrets(&self) -> Result<Vec<String>> {
async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass")
@@ -126,21 +153,23 @@ impl SecretProvider for GopassProvider {
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn gopass command")?;
.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)
.context("Failed to read gopass output")?;
.read_to_string(&mut output)?;
let status = child.wait().context("Failed to wait on gopass process")?;
if !status.success() {
return Err(anyhow!("gopass command failed with status: {}", status));
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
@@ -152,7 +181,7 @@ impl SecretProvider for GopassProvider {
Ok(secrets)
}
async fn sync(&mut self) -> Result<()> {
async fn sync(&mut self) -> Result<(), SecretError> {
ensure_gopass_installed()?;
let mut child = Command::new("gopass");
child.arg("sync");
@@ -161,29 +190,32 @@ impl SecretProvider for GopassProvider {
child.args(["-s", store]);
}
let status = child
let output = child
.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set"))
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn gopass command")?
.wait()
.context("Failed to wait on gopass process")?;
.map_err(map_spawn_err)?
.wait_with_output()?;
if !status.success() {
return Err(anyhow!("gopass command failed with status: {}", status));
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<()> {
fn ensure_gopass_installed() -> Result<(), SecretError> {
if which::which("gopass").is_err() {
Err(anyhow!(
"Gopass is not installed or not found in PATH. Please install Gopass from https://gopass.pw/"
))
Err(SecretError::CliNotFound { tool: "gopass" })
} else {
Ok(())
}
+154 -250
View File
@@ -1,21 +1,8 @@
use anyhow::{Context, anyhow, bail};
use secrecy::{ExposeSecret, SecretString};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::{env, fs};
use zeroize::Zeroize;
use crate::config::{Config, get_config_file_path, load_config};
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,
};
use anyhow::Result;
use anyhow::{Context as _, anyhow};
use argon2::{Algorithm, Argon2, Params, Version};
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
use chacha20poly1305::aead::rand_core::RngCore;
@@ -25,10 +12,35 @@ use chacha20poly1305::{
};
use dialoguer::{Input, theme};
use log::{debug, error};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use theme::ColorfulTheme;
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]
/// File-based vault provider with optional Git sync.
@@ -87,12 +99,13 @@ impl SecretProvider for 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: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let envelope = vault
.get(key)
.with_context(|| format!("key '{key}' not found in the vault"))?;
let envelope = vault.get(key).ok_or_else(|| SecretError::NotFound {
key: key.to_string(),
provider: PROVIDER,
})?;
let password = self.get_password()?;
let plaintext = decrypt_string(&password, envelope)?;
@@ -101,69 +114,78 @@ impl SecretProvider for LocalProvider {
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 mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if vault.contains_key(key) {
error!(
"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 envelope = encrypt_string(&password, value)?;
let envelope =
encrypt_string(&password, value).map_err(SecretError::Other)?;
drop(password);
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 mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let password = self.get_password()?;
let envelope = encrypt_string(&password, value)?;
let envelope =
encrypt_string(&password, value).map_err(SecretError::Other)?;
drop(password);
if vault.contains_key(key) {
debug!("Key '{key}' exists in vault. Overwriting previous value");
let vault_entry = vault
.get_mut(key)
.with_context(|| format!("key '{key}' not found in the vault"))?;
let vault_entry = vault.get_mut(key).ok_or_else(|| SecretError::NotFound {
key: key.to_string(),
provider: PROVIDER,
})?;
*vault_entry = envelope;
return store_vault(&vault_path, &vault)
.with_context(|| "failed to save secret to the vault");
return store_vault(&vault_path, &vault);
}
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 mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if !vault.contains_key(key) {
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);
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: 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)
}
async fn sync(&mut self) -> Result<()> {
async fn sync(&mut self) -> LocalResult<()> {
let mut config_changed = false;
let git = resolve_git(self.git_executable.as_ref())?;
ensure_git_available(&git)?;
@@ -174,7 +196,8 @@ impl SecretProvider for LocalProvider {
let branch: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Enter git branch to sync with")
.default("main".into())
.interact_text()?;
.interact_text()
.map_err(|e| cfg_err(format!("prompt failed: {}", e)))?;
self.git_branch = Some(branch);
}
@@ -195,7 +218,8 @@ impl SecretProvider for LocalProvider {
.map(|_| ())
.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);
}
@@ -203,11 +227,15 @@ impl SecretProvider for LocalProvider {
if self.git_user_name.is_none() {
config_changed = true;
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())
.with_prompt("Enter git 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);
}
@@ -215,7 +243,10 @@ impl SecretProvider for LocalProvider {
if self.git_user_email.is_none() {
config_changed = true;
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())
.with_prompt("Enter git user email")
.validate_with({
@@ -228,7 +259,8 @@ impl SecretProvider for LocalProvider {
}
})
.default(default_user_name)
.interact_text()?;
.interact_text()
.map_err(|e| cfg_err(format!("prompt failed: {}", e)))?;
self.git_user_email = Some(branch);
}
@@ -245,15 +277,17 @@ impl SecretProvider for LocalProvider {
git_executable: &self.git_executable,
};
sync_and_push(&sync_opts)
sync_and_push(&sync_opts)?;
Ok(())
}
}
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)");
let mut cfg = load_config(true).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 mut updated = false;
@@ -280,26 +314,30 @@ impl LocalProvider {
}
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("");
if ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml") {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let s = serde_yaml::to_string(&cfg)?;
fs::write(&path, s).with_context(|| format!("failed to write {}", path.display()))?;
let s = serde_yaml::to_string(&cfg)
.map_err(|e| cfg_err(format!("serialize config: {}", e)))?;
fs::write(&path, s)?;
} else {
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(())
}
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 {
let name = repo_name_from_url(remote);
let dir = base_config_dir()?.join(format!(".{}", name));
@@ -309,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()?
&& dir.exists()
{
@@ -319,27 +357,24 @@ impl LocalProvider {
default_vault_path()
}
fn get_password(&self) -> Result<SecretString> {
fn get_password(&self) -> LocalResult<SecretString> {
if let Some(password_file) = &self.password_file {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(password_file).with_context(|| {
format!("failed to read password file metadata {:?}", password_file)
})?;
let metadata = fs::metadata(password_file)?;
let mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
bail!(
return Err(cfg_err(format!(
"password file {:?} has insecure permissions {:o} (should be 0600 or 0400)",
password_file,
mode & 0o777
);
)));
}
}
let password = SecretString::new(
fs::read_to_string(password_file)
.with_context(|| format!("failed to read password file {:?}", password_file))?
fs::read_to_string(password_file)?
.trim()
.to_string()
.into(),
@@ -353,7 +388,7 @@ 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);
if let Some(xdg) = xdg_path {
@@ -361,17 +396,17 @@ fn default_vault_path() -> Result<PathBuf> {
}
confy::get_configuration_file_path(&calling_app_name(), "vault")
.with_context(|| "get config dir")
.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()?
.parent()
.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() {
return Ok(HashMap::new());
}
@@ -380,26 +415,26 @@ fn load_vault(path: &Path) -> Result<HashMap<String, String>> {
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() {
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")?;
fs::write(path, &s).with_context(|| format!("write {}", path.display()))?;
let s = serde_yaml::to_string(map)
.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))
.with_context(|| format!("set permissions on {}", path.display()))?;
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() {
bail!("password cannot be empty");
anyhow::bail!("password cannot be empty");
}
let mut salt = [0u8; SALT_LEN];
@@ -455,7 +490,7 @@ fn derive_key_with_params(
m_cost: u32,
t_cost: u32,
p: u32,
) -> Result<Key> {
) -> anyhow::Result<Key> {
let params = Params::new(m_cost, t_cost, p, Some(KEY_LEN))
.map_err(|e| anyhow!("argon2 params error: {:?}", e))?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
@@ -468,11 +503,10 @@ fn derive_key_with_params(
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)
}
/// Attempts to decrypt with the given cipher, nonce, ciphertext, and AAD.
fn try_decrypt(
cipher: &XChaCha20Poly1305,
nonce: &XNonce,
@@ -484,25 +518,29 @@ fn try_decrypt(
type EnvelopeComponents = (u32, u32, u32, Vec<u8>, [u8; NONCE_LEN], Vec<u8>);
/// Parse an envelope string and extract its components.
/// Returns (m, t, p, salt, nonce_arr, ct) on success.
fn parse_envelope(envelope: &str) -> Result<EnvelopeComponents> {
fn parse_envelope(envelope: &str) -> LocalResult<EnvelopeComponents> {
let parts: Vec<&str> = envelope.trim().split(';').collect();
if parts.len() < 7 {
debug!("Invalid envelope format: {:?}", parts);
bail!("invalid envelope format");
return Err(SecretError::Other(anyhow!("invalid envelope format")));
}
if parts[0] != HEADER {
debug!("Invalid header: {}", parts[0]);
bail!("unexpected header");
return Err(SecretError::Other(anyhow!("unexpected header")));
}
if parts[1] != VERSION {
debug!("Unsupported version: {}", parts[1]);
bail!("unsupported version {}", parts[1]);
return Err(SecretError::Unsupported {
operation: "decrypt_envelope_version",
provider: PROVIDER,
});
}
if parts[2] != KDF {
debug!("Unsupported kdf: {}", parts[2]);
bail!("unsupported kdf {}", parts[2]);
return Err(SecretError::Unsupported {
operation: "decrypt_kdf",
provider: PROVIDER,
});
}
let params_str = parts[3];
@@ -522,31 +560,39 @@ fn parse_envelope(envelope: &str) -> Result<EnvelopeComponents> {
let salt_b64 = parts[4]
.strip_prefix("salt=")
.with_context(|| "missing salt")?;
.ok_or_else(|| SecretError::Other(anyhow!("missing salt")))?;
let nonce_b64 = parts[5]
.strip_prefix("nonce=")
.with_context(|| "missing nonce")?;
let ct_b64 = parts[6].strip_prefix("ct=").with_context(|| "missing ct")?;
.ok_or_else(|| SecretError::Other(anyhow!("missing nonce")))?;
let ct_b64 = parts[6]
.strip_prefix("ct=")
.ok_or_else(|| SecretError::Other(anyhow!("missing ct")))?;
let salt = B64.decode(salt_b64).with_context(|| "bad salt b64")?;
let nonce_bytes = B64.decode(nonce_b64).with_context(|| "bad nonce b64")?;
let ct = B64.decode(ct_b64).with_context(|| "bad ct b64")?;
let salt = B64
.decode(salt_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 nonce_bytes.len() != NONCE_LEN {
debug!("Nonce length mismatch: {}", nonce_bytes.len());
bail!("nonce length mismatch");
return Err(SecretError::Other(anyhow!("nonce length mismatch")));
}
let nonce_arr: [u8; NONCE_LEN] = nonce_bytes
.try_into()
.map_err(|_| anyhow!("invalid nonce length"))?;
.map_err(|_| SecretError::Other(anyhow!("invalid nonce length")))?;
Ok((m, t, p, salt, nonce_arr, ct))
}
fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> {
fn decrypt_string(password: &SecretString, envelope: &str) -> LocalResult<String> {
if password.expose_secret().is_empty() {
bail!("password cannot be empty");
return Err(cfg_err("password cannot be empty"));
}
let (m, t, p, mut salt, mut nonce_arr, mut ct) = parse_envelope(envelope)?;
@@ -554,11 +600,16 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> {
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)?;
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);
if let Ok(pt) = try_decrypt(&cipher, &nonce, &ct, aad_current.as_bytes()) {
let s = String::from_utf8(pt.clone()).with_context(|| "plaintext not valid UTF-8")?;
let s = String::from_utf8(pt.clone())
.map_err(|e| SecretError::Other(anyhow!("plaintext not valid UTF-8: {}", e)))?;
key.zeroize();
salt.zeroize();
nonce_arr.zeroize();
@@ -571,169 +622,22 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> {
nonce_arr.zeroize();
ct.zeroize();
// TODO: Remove once all users have migrated their local vaults
if let Ok(plaintext) = legacy::decrypt_string_legacy(password, envelope) {
return Ok(plaintext);
}
bail!("decryption failed (wrong password or corrupted data)")
}
// TODO: Remove this entire module once all users have migrated their vaults.
mod legacy {
use super::*;
fn legacy_aad() -> String {
format!("{};{}", HEADER, VERSION)
}
pub fn decrypt_string_legacy(password: &SecretString, envelope: &str) -> Result<String> {
if password.expose_secret().is_empty() {
bail!("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 = legacy_aad();
let mut key = derive_key_with_params(password, &salt, m, t, p)?;
let cipher = XChaCha20Poly1305::new(&key);
if let Ok(pt) = try_decrypt(&cipher, &nonce, &ct, aad.as_bytes()) {
let s = String::from_utf8(pt.clone()).with_context(|| "plaintext not valid UTF-8")?;
key.zeroize();
salt.zeroize();
nonce_arr.zeroize();
ct.zeroize();
return Ok(s);
}
key.zeroize();
let mut zeros_key: Key = [0u8; KEY_LEN].into();
let zeros_cipher = XChaCha20Poly1305::new(&zeros_key);
if let Ok(pt) = try_decrypt(&zeros_cipher, &nonce, &ct, aad.as_bytes()) {
debug!("Decrypted using legacy all-zeros key - secret needs migration");
let s = String::from_utf8(pt.clone()).with_context(|| "plaintext not valid UTF-8")?;
zeros_key.zeroize();
salt.zeroize();
nonce_arr.zeroize();
ct.zeroize();
return Ok(s);
}
zeros_key.zeroize();
salt.zeroize();
nonce_arr.zeroize();
ct.zeroize();
bail!("legacy decryption failed")
}
pub fn is_current_format(password: &SecretString, envelope: &str) -> Result<bool> {
if password.expose_secret().is_empty() {
bail!("password cannot be empty");
}
let (m, t, p, salt, nonce_arr, ct) = parse_envelope(envelope)?;
let nonce: XNonce = nonce_arr.into();
let aad_current = format!("{};{};{};m={},t={},p={}", HEADER, VERSION, KDF, m, t, p);
let key = derive_key_with_params(password, &salt, m, t, p)?;
let cipher = XChaCha20Poly1305::new(&key);
Ok(try_decrypt(&cipher, &nonce, &ct, aad_current.as_bytes()).is_ok())
}
}
// TODO: Remove once all users have migrated their local vaults
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretStatus {
Current,
NeedsMigration,
}
// TODO: Remove once all users have migrated their local vaults
#[derive(Debug)]
pub struct MigrationResult {
pub total: usize,
pub migrated: usize,
pub already_current: usize,
pub failed: Vec<(String, String)>,
}
impl LocalProvider {
// TODO: Remove once all users have migrated their local vaults
pub async fn migrate_vault(&self) -> Result<MigrationResult> {
let vault_path = self.active_vault_path()?;
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if vault.is_empty() {
return Ok(MigrationResult {
total: 0,
migrated: 0,
already_current: 0,
failed: vec![],
});
}
let password = self.get_password()?;
let mut migrated_vault = HashMap::new();
let mut migrated_count = 0;
let mut already_current_count = 0;
let mut failed = vec![];
for (key, envelope) in &vault {
match legacy::is_current_format(&password, envelope) {
Ok(true) => {
migrated_vault.insert(key.clone(), envelope.clone());
already_current_count += 1;
}
Ok(false) => match decrypt_string(&password, envelope) {
Ok(plaintext) => match encrypt_string(&password, &plaintext) {
Ok(new_envelope) => {
migrated_vault.insert(key.clone(), new_envelope);
migrated_count += 1;
}
Err(e) => {
failed.push((key.clone(), format!("re-encryption failed: {}", e)));
migrated_vault.insert(key.clone(), envelope.clone());
}
},
Err(e) => {
failed.push((key.clone(), format!("decryption failed: {}", e)));
migrated_vault.insert(key.clone(), envelope.clone());
}
},
Err(e) => {
failed.push((key.clone(), format!("status check failed: {}", e)));
migrated_vault.insert(key.clone(), envelope.clone());
}
}
}
if migrated_count > 0 {
store_vault(&vault_path, &migrated_vault)?;
}
Ok(MigrationResult {
total: vault.len(),
migrated: migrated_count,
already_current: already_current_count,
failed,
})
}
Err(SecretError::AuthFailed {
provider: PROVIDER,
source: anyhow!("decryption failed (wrong password or corrupted data)"),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::env as std_env;
use pretty_assertions::assert_eq;
use secrecy::{ExposeSecret, SecretString};
use std::env as std_env;
use tempfile::tempdir;
use super::*;
#[test]
fn test_derive_key() {
let password = SecretString::new("test_password".to_string().into());
+42 -24
View File
@@ -4,23 +4,30 @@
//! interface used by the CLI.
pub mod aws_secrets_manager;
pub mod azure_key_vault;
pub mod error;
pub mod gcp_secret_manager;
mod git_sync;
pub mod git_sync;
pub mod gopass;
pub mod local;
pub mod one_password;
use crate::providers::gopass::GopassProvider;
use crate::providers::local::LocalProvider;
use anyhow::{Context, Result, anyhow};
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 std::fmt::{Display, Formatter};
use std::{env, fmt};
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"));
@@ -29,26 +36,26 @@ pub(in crate::providers) static ENV_PATH: Lazy<Result<String>> =
#[async_trait::async_trait]
pub trait SecretProvider: Send + Sync {
fn name(&self) -> &'static str;
async fn get_secret(&self, key: &str) -> Result<String>;
async fn set_secret(&self, key: &str, value: &str) -> Result<()>;
async fn update_secret(&self, _key: &str, _value: &str) -> Result<()> {
Err(anyhow!(
"update secret not supported for provider {}",
self.name()
))
async fn get_secret(&self, key: &str) -> Result<String, SecretError>;
async fn set_secret(&self, key: &str, value: &str) -> Result<(), SecretError>;
async fn update_secret(&self, _key: &str, _value: &str) -> Result<(), SecretError> {
Err(SecretError::Unsupported {
operation: "update_secret",
provider: self.name(),
})
}
async fn delete_secret(&self, key: &str) -> Result<()>;
async fn list_secrets(&self) -> Result<Vec<String>> {
Err(anyhow!(
"list secrets is not supported for the provider {}",
self.name()
))
async fn delete_secret(&self, key: &str) -> Result<(), SecretError>;
async fn list_secrets(&self) -> Result<Vec<String>, SecretError> {
Err(SecretError::Unsupported {
operation: "list_secrets",
provider: self.name(),
})
}
async fn sync(&mut self) -> Result<()> {
Err(anyhow!(
"sync is not supported for the provider {}",
self.name()
))
async fn sync(&mut self) -> Result<(), SecretError> {
Err(SecretError::Unsupported {
operation: "sync",
provider: self.name(),
})
}
}
@@ -76,6 +83,10 @@ pub enum SupportedProvider {
#[serde(flatten)]
provider_def: GopassProvider,
},
OnePassword {
#[serde(flatten)]
provider_def: OnePasswordProvider,
},
}
impl Validate for SupportedProvider {
@@ -86,6 +97,7 @@ impl Validate for SupportedProvider {
SupportedProvider::GcpSecretManager { 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(),
}
}
}
@@ -106,6 +118,12 @@ impl Display for SupportedProvider {
SupportedProvider::GcpSecretManager { .. } => write!(f, "gcp_secret_manager"),
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(())
}
}
+1
View File
@@ -3,4 +3,5 @@ mod azure_key_vault_tests;
mod gcp_secret_manager_tests;
mod gopass_tests;
mod local_tests;
mod one_password_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"));
}