From 47d2541b0f07558a8663c996fefbfd79a69845a7 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 2 Jun 2026 12:16:11 -0600 Subject: [PATCH] feat: Added typed errors to improve library DevX and removed now deprecated migrate command --- Cargo.toml | 1 + src/bin/gman/main.rs | 49 ---- src/lib.rs | 2 + src/providers/aws_secrets_manager.rs | 68 +++-- src/providers/azure_key_vault.rs | 93 +++++-- src/providers/error.rs | 225 +++++++++++++++ src/providers/gcp_secret_manager.rs | 67 +++-- src/providers/git_sync.rs | 287 ++++++++++++------- src/providers/gopass.rs | 120 +++++--- src/providers/local.rs | 401 ++++++++++----------------- src/providers/mod.rs | 60 ++-- src/providers/one_password.rs | 123 +++++--- 12 files changed, 903 insertions(+), 593 deletions(-) create mode 100644 src/providers/error.rs diff --git a/Cargo.toml b/Cargo.toml index 2aa1774..0b80f5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ azure_security_keyvault_secrets = "0.10.0" aws-lc-sys = { version = "0.39.0", features = ["bindgen"] } which = "8.0.0" once_cell = "1.21.3" +thiserror = "2" [target.'cfg(all(target_os="linux", target_env="musl"))'.dependencies] openssl = { version = "0.10", features = ["vendored"] } diff --git a/src/bin/gman/main.rs b/src/bin/gman/main.rs index a01f4a2..53fd874 100644 --- a/src/bin/gman/main.rs +++ b/src/bin/gman/main.rs @@ -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?; } diff --git a/src/lib.rs b/src/lib.rs index 3acc808..8036de4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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"; diff --git a/src/providers/aws_secrets_manager.rs b/src/providers/aws_secrets_manager.rs index 588d333..96b56ee 100644 --- a/src/providers/aws_secrets_manager.rs +++ b/src/providers/aws_secrets_manager.rs @@ -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 { - self.get_client() - .await? + async fn get_secret(&self, key: &str) -> Result { + 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> { - self.get_client() + async fn list_secrets(&self) -> Result, 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 { - 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 { + 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)) diff --git a/src/providers/azure_key_vault.rs b/src/providers/azure_key_vault.rs index 8925857..49ffeb9 100644 --- a/src/providers/azure_key_vault.rs +++ b/src/providers/azure_key_vault.rs @@ -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 { - let response = self.get_client()?.get_secret(key, None).await?; - let body = response.into_model()?; + async fn get_secret(&self, key: &str) -> Result { + 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> { - let mut pager = self.get_client()?.list_secret_properties(None)?; + async fn list_secrets(&self) -> Result, 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 { - let credential: Arc = DeveloperToolsCredential::new(None)?; + fn get_client(&self) -> Result { + let credential: Arc = + 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) } diff --git a/src/providers/error.rs b/src/providers/error.rs new file mode 100644 index 0000000..fe5e001 --- /dev/null +++ b/src/providers/error.rs @@ -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 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::>() + .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::() { + 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::>() + .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::() { + 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::>() + .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) + } +} diff --git a/src/providers/gcp_secret_manager.rs b/src/providers/gcp_secret_manager.rs index df3cec6..46e7ddc 100644 --- a/src/providers/gcp_secret_manager.rs +++ b/src/providers/gcp_secret_manager.rs @@ -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>; +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 { - let secret_value = self + async fn get_secret(&self, key: &str) -> Result { + 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> { + async fn list_secrets(&self) -> Result, 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 { + async fn get_client(&self) -> Result { 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) } diff --git a/src/providers/git_sync.rs b/src/providers/git_sync.rs index 4edf9dc..9a2790e 100644 --- a/src/providers/git_sync.rs +++ b/src/providers/git_sync.rs @@ -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 = std::result::Result; + #[derive(Debug, Validate, Clone)] pub struct SyncOpts<'a> { #[validate(required)] @@ -21,38 +57,36 @@ pub struct SyncOpts<'a> { pub git_executable: &'a Option, } -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 { +fn resolve_git_username(git: &Path, name: Option<&String>) -> SyncResult { 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 { +fn resolve_git_email(git: &Path, email: Option<&String>) -> SyncResult { 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 { +pub(in crate::providers) fn resolve_git( + override_path: Option<&PathBuf>, +) -> SyncResult { 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 { +pub(in crate::providers) fn default_git_username(git: &Path) -> SyncResult { 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 { +pub(in crate::providers) fn default_git_email(git: &Path) -> SyncResult { 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 { +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 { + 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"); } diff --git a/src/providers/gopass.rs b/src/providers/gopass.rs index a0de4e1..a0183d2 100644 --- a/src/providers/gopass.rs +++ b/src/providers/gopass.rs @@ -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 { + async fn get_secret(&self, key: &str) -> Result { 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> { + async fn list_secrets(&self) -> Result, 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 = 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(()) } diff --git a/src/providers/local.rs b/src/providers/local.rs index 73e3e99..77353f5 100644 --- a/src/providers/local.rs +++ b/src/providers/local.rs @@ -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 = std::result::Result; + +fn cfg_err(message: impl Into) -> 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 { + async fn get_secret(&self, key: &str) -> LocalResult { let vault_path = self.active_vault_path()?; let vault: HashMap = 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,61 +114,69 @@ 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 = 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 = 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 = 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> { + async fn list_secrets(&self) -> LocalResult> { let vault_path = self.active_vault_path()?; let vault: HashMap = load_vault(&vault_path).unwrap_or_default(); let mut keys: Vec = vault.keys().cloned().collect(); @@ -164,7 +185,7 @@ impl SecretProvider for LocalProvider { 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)?; @@ -175,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); } @@ -196,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); } @@ -204,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); } @@ -216,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({ @@ -229,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); } @@ -246,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; @@ -281,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> { + fn repo_dir_for_config(&self) -> LocalResult> { if let Some(remote) = &self.git_remote_url { let name = repo_name_from_url(remote); let dir = base_config_dir()?.join(format!(".{}", name)); @@ -310,7 +347,7 @@ impl LocalProvider { } } - fn active_vault_path(&self) -> Result { + fn active_vault_path(&self) -> LocalResult { if let Some(dir) = self.repo_dir_for_config()? && dir.exists() { @@ -320,27 +357,24 @@ impl LocalProvider { default_vault_path() } - fn get_password(&self) -> Result { + fn get_password(&self) -> LocalResult { 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(), @@ -354,7 +388,7 @@ impl LocalProvider { } } -fn default_vault_path() -> Result { +fn default_vault_path() -> LocalResult { let xdg_path = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from); if let Some(xdg) = xdg_path { @@ -362,17 +396,17 @@ fn default_vault_path() -> Result { } 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 { +fn base_config_dir() -> LocalResult { 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> { +fn load_vault(path: &Path) -> anyhow::Result> { if !path.exists() { return Ok(HashMap::new()); } @@ -381,26 +415,26 @@ fn load_vault(path: &Path) -> Result> { Ok(map) } -fn store_vault(path: &Path, map: &HashMap) -> Result<()> { +fn store_vault(path: &Path, map: &HashMap) -> 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 { +fn encrypt_string(password: &SecretString, plaintext: &str) -> anyhow::Result { if password.expose_secret().is_empty() { - bail!("password cannot be empty"); + anyhow::bail!("password cannot be empty"); } let mut salt = [0u8; SALT_LEN]; @@ -456,7 +490,7 @@ fn derive_key_with_params( m_cost: u32, t_cost: u32, p: u32, -) -> Result { +) -> anyhow::Result { 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); @@ -469,11 +503,10 @@ fn derive_key_with_params( Ok(key) } -fn derive_key(password: &SecretString, salt: &[u8]) -> Result { +fn derive_key(password: &SecretString, salt: &[u8]) -> anyhow::Result { 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, @@ -485,25 +518,29 @@ fn try_decrypt( type EnvelopeComponents = (u32, u32, u32, Vec, [u8; NONCE_LEN], Vec); -/// Parse an envelope string and extract its components. -/// Returns (m, t, p, salt, nonce_arr, ct) on success. -fn parse_envelope(envelope: &str) -> Result { +fn parse_envelope(envelope: &str) -> LocalResult { 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]; @@ -523,31 +560,39 @@ fn parse_envelope(envelope: &str) -> Result { 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 { +fn decrypt_string(password: &SecretString, envelope: &str) -> LocalResult { 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)?; @@ -555,11 +600,16 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result { 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(); @@ -572,169 +622,22 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result { 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 { - 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 { - 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 { - let vault_path = self.active_vault_path()?; - let vault: HashMap = 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()); diff --git a/src/providers/mod.rs b/src/providers/mod.rs index d4a9c79..27b04e9 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -4,25 +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 crate::providers::one_password::OnePasswordProvider; -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> = Lazy::new(|| env::var("PATH").context("No PATH environment variable")); @@ -31,26 +36,26 @@ pub(in crate::providers) static ENV_PATH: Lazy> = #[async_trait::async_trait] pub trait SecretProvider: Send + Sync { fn name(&self) -> &'static str; - async fn get_secret(&self, key: &str) -> Result; - 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; + 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> { - 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, 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(), + }) } } @@ -117,3 +122,8 @@ impl Display for SupportedProvider { } } } + +#[allow(unused_imports)] +pub(crate) use crate::providers::error::{ + classify_aws_error, classify_azure_error, classify_gcp_error, +}; diff --git a/src/providers/one_password.rs b/src/providers/one_password.rs index e05bdd9..21ed95e 100644 --- a/src/providers/one_password.rs +++ b/src/providers/one_password.rs @@ -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; 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 @@ -53,13 +66,33 @@ impl OnePasswordProvider { } } +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 { + async fn get_secret(&self, key: &str) -> Result { ensure_op_installed()?; let mut cmd = self.base_command(); @@ -67,27 +100,27 @@ impl SecretProvider for OnePasswordProvider { cmd.args(self.vault_args()); cmd.stdin(Stdio::inherit()) .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); + .stderr(Stdio::piped()); - let mut child = cmd.spawn().context("Failed to spawn op command")?; + 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) - .context("Failed to read op output")?; + .read_to_string(&mut output)?; - let status = child.wait().context("Failed to wait on op process")?; - if !status.success() { - return Err(anyhow!("op command failed with status: {}", status)); + 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<()> { + async fn set_secret(&self, key: &str, value: &str) -> Result<(), SecretError> { ensure_op_installed()?; let mut cmd = self.base_command(); @@ -96,19 +129,20 @@ impl SecretProvider for OnePasswordProvider { cmd.arg(format!("password={}", value)); cmd.stdin(Stdio::inherit()) .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); + .stderr(Stdio::piped()); - let mut child = cmd.spawn().context("Failed to spawn op command")?; + let child = cmd.spawn().map_err(map_spawn_err)?; - let status = child.wait().context("Failed to wait on op process")?; - if !status.success() { - return Err(anyhow!("op command failed with status: {}", status)); + 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<()> { + async fn update_secret(&self, key: &str, value: &str) -> Result<(), SecretError> { ensure_op_installed()?; let mut cmd = self.base_command(); @@ -117,19 +151,20 @@ impl SecretProvider for OnePasswordProvider { cmd.arg(format!("password={}", value)); cmd.stdin(Stdio::inherit()) .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); + .stderr(Stdio::piped()); - let mut child = cmd.spawn().context("Failed to spawn op command")?; + let child = cmd.spawn().map_err(map_spawn_err)?; - let status = child.wait().context("Failed to wait on op process")?; - if !status.success() { - return Err(anyhow!("op command failed with status: {}", status)); + 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<()> { + async fn delete_secret(&self, key: &str) -> Result<(), SecretError> { ensure_op_installed()?; let mut cmd = self.base_command(); @@ -137,19 +172,20 @@ impl SecretProvider for OnePasswordProvider { cmd.args(self.vault_args()); cmd.stdin(Stdio::inherit()) .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); + .stderr(Stdio::piped()); - let mut child = cmd.spawn().context("Failed to spawn op command")?; + let child = cmd.spawn().map_err(map_spawn_err)?; - let status = child.wait().context("Failed to wait on op process")?; - if !status.success() { - return Err(anyhow!("op command failed with status: {}", status)); + 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> { + async fn list_secrets(&self) -> Result, SecretError> { ensure_op_installed()?; let mut cmd = self.base_command(); @@ -157,25 +193,25 @@ impl SecretProvider for OnePasswordProvider { cmd.args(self.vault_args()); cmd.stdin(Stdio::inherit()) .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); + .stderr(Stdio::piped()); - let mut child = cmd.spawn().context("Failed to spawn op command")?; + 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) - .context("Failed to read op output")?; + .read_to_string(&mut output)?; - let status = child.wait().context("Failed to wait on op process")?; - if !status.success() { - return Err(anyhow!("op command failed with status: {}", status)); + 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::from_str(&output).context("Failed to parse op item list JSON output")?; + let items: Vec = serde_json::from_str(&output) + .map_err(|e| SecretError::Other(anyhow!("failed to parse op output: {}", e)))?; let secrets: Vec = items .iter() @@ -187,12 +223,9 @@ impl SecretProvider for OnePasswordProvider { } } -fn ensure_op_installed() -> Result<()> { +fn ensure_op_installed() -> Result<(), SecretError> { if which::which("op").is_err() { - Err(anyhow!( - "1Password CLI (op) is not installed or not found in PATH. \ - Please install it from https://developer.1password.com/docs/cli/get-started/" - )) + Err(SecretError::CliNotFound { tool: "op" }) } else { Ok(()) }