14 Commits

Author SHA1 Message Date
github-actions[bot]
c0aa379b20 bump: version 0.2.3 → 0.3.0 [skip ci] 2026-02-02 01:08:03 +00:00
f9fd9692aa build: Modified integration tests so they don't run when cross-compiling to non-x86 systems
Check / stable / fmt (push) Successful in 9m54s
Check / beta / clippy (push) Failing after 39s
Check / stable / clippy (push) Failing after 40s
Check / nightly / doc (push) Failing after 37s
Check / 1.89.0 / check (push) Failing after 38s
Test Suite / ubuntu / beta (push) Failing after 38s
Test Suite / ubuntu / stable (push) Failing after 39s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m3s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-02-01 18:03:51 -07:00
2615b23d6e test: Removed deprecated function calls from cli_tests module and sped up proptests
Check / stable / fmt (push) Successful in 9m55s
Check / beta / clippy (push) Failing after 38s
Check / stable / clippy (push) Failing after 39s
Check / nightly / doc (push) Failing after 37s
Check / 1.89.0 / check (push) Failing after 38s
Test Suite / ubuntu / beta (push) Failing after 38s
Test Suite / ubuntu / stable (push) Failing after 39s
Test Suite / ubuntu / stable / coverage (push) Failing after 1m28s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-02-01 17:14:24 -07:00
628a13011e build: upgraded to the most recent Azure SDK version 2026-02-01 16:44:28 -07:00
cff4420ee0 fix: Upgraded AWS dependencies to address CWE-20 2026-02-01 16:15:41 -07:00
9944e29ef0 fix: A critical security flaw was discovered that essentially had all local secrets be encrypted with an all-zero key 2026-02-01 16:15:13 -07:00
c95bae1761 fix: Addressed XNonce::from_slice deprecation warning 2026-02-01 14:48:37 -07:00
21da7b782e fix: Secrets are now stored exactly as passed without newlines stripped 2026-02-01 14:47:43 -07:00
d038930ce5 docs: fixed a typo in the mac/linux install script command
Check / stable / fmt (push) Has been cancelled
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2025-11-07 11:39:04 -07:00
github-actions[bot]
f0fc829a73 chore: bump Cargo.toml to 0.2.3 2025-10-14 23:32:36 +00:00
github-actions[bot]
ba0f108aa8 bump: version 0.2.2 → 0.2.3 [skip ci] 2025-10-14 23:32:32 +00:00
6daa6fd2f2 refactor: Refactored the library for gman so that it dynamically names config and password files to be used across any application 2025-10-14 17:12:43 -06:00
5fa4dbfe89 Merge remote-tracking branch 'origin/main' 2025-10-07 10:59:00 -06:00
bdcd496046 docs: fixed typo in code of conduct 2025-10-07 10:58:52 -06:00
18 changed files with 1330 additions and 851 deletions
+15
View File
@@ -5,6 +5,21 @@ 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.3.0 (2026-02-02)
### Fix
- Upgraded AWS dependencies to address CWE-20
- A critical security flaw was discovered that essentially had all local secrets be encrypted with an all-zero key
- Addressed XNonce::from_slice deprecation warning
- Secrets are now stored exactly as passed without newlines stripped
## v0.2.3 (2025-10-14)
### Refactor
- Refactored the library for gman so that it dynamically names config and password files to be used across any application
## v0.2.2 (2025-09-30)
### Refactor
+1 -1
View File
@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
d4udts@gmail.com.
alex.j.tusa@gmail.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
Generated
+747 -674
View File
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "gman"
version = "0.2.2"
version = "0.3.0"
edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "Universal command line secret management and injection tool"
@@ -32,7 +32,7 @@ clap = { version = "4.5.47", features = [
"wrap_help",
] }
clap_complete = { version = "4.5.57", features = ["unstable-dynamic"] }
confy = { version = "1.0.0", default-features = false, features = [
confy = { version = "2.0.0", default-features = false, features = [
"yaml_conf",
] }
crossterm = "0.29.0"
@@ -53,18 +53,19 @@ indoc = "2.0.6"
regex = "1.11.2"
serde_yaml = "0.9.34"
tempfile = "3.22.0"
aws-sdk-secretsmanager = "1.88.0"
aws-sdk-secretsmanager = "1.98.0"
tokio = { version = "1.47.1", features = ["full"] }
aws-config = { version = "1.8.6", features = ["behavior-version-latest"] }
aws-config = { version = "1.8.12", features = ["behavior-version-latest"] }
async-trait = "0.1.89"
futures = "0.3.31"
gcloud-sdk = { version = "0.28.1", features = [
"google-cloud-secretmanager-v1",
] }
crc32c = "0.6.8"
azure_identity = "0.27.0"
azure_security_keyvault_secrets = "0.6.0"
aws-lc-sys = { version = "0.31.0", features = ["bindgen"] }
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"] }
which = "8.0.0"
once_cell = "1.21.3"
+1 -1
View File
@@ -142,7 +142,7 @@ You can use the following command to run a bash script that downloads and instal
OS (Linux/MacOS) and architecture (x86_64/arm64):
```shell
curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/gman/main/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/Dark-Alex-17/gman/main/install_gman.sh | bash
```
#### Windows/Linux/MacOS (`PowerShell`)
+10 -6
View File
@@ -323,6 +323,7 @@ pub fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
mod tests {
use super::*;
use crate::cli::generate_files_secret_injections;
use gman::config::get_config_file_path;
use gman::config::{Config, RunConfig};
use pretty_assertions::{assert_eq, assert_str_eq};
use serial_test::serial;
@@ -436,9 +437,10 @@ mod tests {
fn test_run_config_completer_filters_by_prefix() {
let td = tempdir().unwrap();
let xdg = td.path().join("xdg");
let app_dir = xdg.join("gman");
fs::create_dir_all(&app_dir).unwrap();
unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg) };
let cfg_path = get_config_file_path().unwrap();
let app_dir = cfg_path.parent().unwrap().to_path_buf();
fs::create_dir_all(&app_dir).unwrap();
let yaml = indoc::indoc! {
"---
@@ -471,9 +473,10 @@ mod tests {
fn test_provider_completer_lists_matching_providers() {
let td = tempdir().unwrap();
let xdg = td.path().join("xdg");
let app_dir = xdg.join("gman");
fs::create_dir_all(&app_dir).unwrap();
unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg) };
let cfg_path = get_config_file_path().unwrap();
let app_dir = cfg_path.parent().unwrap().to_path_buf();
fs::create_dir_all(&app_dir).unwrap();
let yaml = indoc::indoc! {
"---
@@ -508,9 +511,10 @@ mod tests {
async fn test_secrets_completer_filters_keys_by_prefix() {
let td = tempdir().unwrap();
let xdg = td.path().join("xdg");
let app_dir = xdg.join("gman");
fs::create_dir_all(&app_dir).unwrap();
unsafe { std_env::set_var("XDG_CONFIG_HOME", &xdg) };
let cfg_path = get_config_file_path().unwrap();
let app_dir = cfg_path.parent().unwrap().to_path_buf();
fs::create_dir_all(&app_dir).unwrap();
let yaml = indoc::indoc! {
"---
+51 -2
View File
@@ -116,6 +116,12 @@ 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 {},
@@ -159,7 +165,7 @@ async fn main() -> Result<()> {
let plaintext =
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
secrets_provider
.set_secret(&name, plaintext.trim_end())
.set_secret(&name, &plaintext)
.await
.map(|_| match cli.output {
Some(_) => (),
@@ -190,7 +196,7 @@ async fn main() -> Result<()> {
let plaintext =
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
secrets_provider
.update_secret(&name, plaintext.trim_end())
.update_secret(&name, &plaintext)
.await
.map(|_| match cli.output {
Some(_) => (),
@@ -258,6 +264,49 @@ 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?;
}
+2 -2
View File
@@ -46,7 +46,7 @@ pub fn init_logging_config() -> log4rs::Config {
pub fn get_log_path() -> PathBuf {
let base_dir = dirs::cache_dir().unwrap_or_else(env::temp_dir);
let log_dir = base_dir.join("gman");
let log_dir = base_dir.join(env!("CARGO_CRATE_NAME"));
let dir = if let Err(e) = fs::create_dir_all(&log_dir) {
eprintln!(
@@ -77,7 +77,7 @@ pub fn persist_config_file(config: &Config) -> Result<()> {
fs::write(&config_path, s)
.with_context(|| format!("failed to write {}", config_path.display()))?;
} else {
confy::store("gman", "config", config)
confy::store(env!("CARGO_CRATE_NAME"), "config", config)
.with_context(|| "failed to save updated config via confy")?;
}
+16 -15
View File
@@ -21,6 +21,7 @@
//! rc.validate().unwrap();
//! ```
use crate::calling_app_name;
use crate::providers::local::LocalProvider;
use crate::providers::{SecretProvider, SupportedProvider};
use anyhow::{Context, Result};
@@ -268,21 +269,18 @@ impl Config {
/// Discover the default password file for the local provider.
///
/// On most systems this resolves to `~/.gman_password` when the file
/// exists, otherwise `None`.
pub fn local_provider_password_file() -> Option<PathBuf> {
let candidate = dirs::home_dir().map(|p| p.join(".gman_password"));
match candidate {
Some(p) if p.exists() => Some(p),
_ => None,
}
/// On most systems this resolves to `~/.<executable_name>_password`
pub fn local_provider_password_file() -> PathBuf {
dirs::home_dir()
.map(|p| p.join(format!(".{}_password", calling_app_name())))
.expect("unable to determine home directory for local provider password file")
}
}
/// Load and validate the application configuration.
///
/// This uses the `confy` crate to load the configuration from a file
/// (e.g. `~/.config/gman/config.yaml`). If the file does
/// (e.g. `~/.config/<executable_name>/config.yaml`). If the file does
/// not exist, a default configuration is created and saved.
///
/// ```no_run
@@ -295,7 +293,7 @@ pub fn load_config(interpolate: bool) -> Result<Config> {
let xdg_path = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from);
let mut config: Config = if let Some(base) = xdg_path.as_ref() {
let app_dir = base.join("gman");
let app_dir = base.join(calling_app_name());
let yml = app_dir.join("config.yml");
let yaml = app_dir.join("config.yaml");
if yml.exists() || yaml.exists() {
@@ -327,9 +325,9 @@ pub fn load_config(interpolate: bool) -> Result<Config> {
ref mut provider_def,
} = p.provider_type
&& provider_def.password_file.is_none()
&& let Some(local_password_file) = Config::local_provider_password_file()
&& Config::local_provider_password_file().exists()
{
provider_def.password_file = Some(local_password_file);
provider_def.password_file = Some(Config::local_provider_password_file());
}
});
@@ -337,7 +335,7 @@ pub fn load_config(interpolate: bool) -> Result<Config> {
}
fn load_confy_config(interpolate: bool) -> Result<Config> {
let load_path = confy::get_configuration_file_path("gman", "config")?;
let load_path = confy::get_configuration_file_path(&calling_app_name(), "config")?;
let mut content = fs::read_to_string(&load_path)
.with_context(|| format!("failed to read config file '{}'", load_path.display()))?;
if interpolate {
@@ -352,7 +350,7 @@ fn load_confy_config(interpolate: bool) -> Result<Config> {
/// Returns the configuration file path that `confy` will use
pub fn get_config_file_path() -> Result<PathBuf> {
if let Some(base) = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) {
let dir = base.join("gman");
let dir = base.join(calling_app_name());
let yml = dir.join("config.yml");
let yaml = dir.join("config.yaml");
if yml.exists() || yaml.exists() {
@@ -360,7 +358,10 @@ pub fn get_config_file_path() -> Result<PathBuf> {
}
return Ok(dir.join("config.yml"));
}
Ok(confy::get_configuration_file_path("gman", "config")?)
Ok(confy::get_configuration_file_path(
&calling_app_name(),
"config",
)?)
}
pub fn interpolate_env_vars(s: &str) -> String {
+77 -35
View File
@@ -20,17 +20,16 @@
//! The `config` and `providers` modules power the CLI. They can be embedded
//! in other programs, but many functions interact with the user or the
//! filesystem. Prefer `no_run` doctests for those.
use anyhow::{Context, Result, anyhow, bail};
use argon2::{
Algorithm, Argon2, Params, Version,
password_hash::{SaltString, rand_core::RngCore},
};
use argon2::{Algorithm, Argon2, Params, Version, password_hash::rand_core::RngCore};
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
use chacha20poly1305::{
Key, XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, OsRng},
};
use secrecy::{ExposeSecret, SecretString};
use std::path::PathBuf;
use zeroize::Zeroize;
/// Configuration structures and helpers used by the CLI and library.
pub mod config;
@@ -41,8 +40,8 @@ pub(crate) const HEADER: &str = "$VAULT";
pub(crate) const VERSION: &str = "v1";
pub(crate) const KDF: &str = "argon2id";
pub(crate) const ARGON_M_COST_KIB: u32 = 19_456;
pub(crate) const ARGON_T_COST: u32 = 2;
pub(crate) const ARGON_M_COST_KIB: u32 = 65_536;
pub(crate) const ARGON_T_COST: u32 = 3;
pub(crate) const ARGON_P: u32 = 1;
pub(crate) const SALT_LEN: usize = 16;
@@ -59,7 +58,7 @@ fn derive_key(password: &SecretString, salt: &[u8]) -> Result<Key> {
.hash_password_into(password.expose_secret().as_bytes(), salt, &mut key_bytes)
.map_err(|e| anyhow!("argon2 into error: {:?}", e))?;
let key = *Key::from_slice(&key_bytes);
let key: Key = key_bytes.into();
key_bytes.zeroize();
Ok(key)
}
@@ -82,20 +81,28 @@ fn derive_key(password: &SecretString, salt: &[u8]) -> Result<Key> {
pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Result<String> {
let password = password.into();
let salt = SaltString::generate(&mut OsRng);
if password.expose_secret().is_empty() {
bail!("password cannot be empty");
}
let mut salt = [0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt);
let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes);
let key = derive_key(&password, salt.as_str().as_bytes())?;
let mut key = derive_key(&password, &salt)?;
let cipher = XChaCha20Poly1305::new(&key);
let aad = format!("{};{}", HEADER, VERSION);
let aad = format!(
"{};{};{};m={},t={},p={}",
HEADER, VERSION, KDF, ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P
);
let nonce = XNonce::from_slice(&nonce_bytes);
let nonce: XNonce = nonce_bytes.into();
let mut pt = plaintext.as_bytes().to_vec();
let ct = cipher
.encrypt(
nonce,
&nonce,
chacha20poly1305::aead::Payload {
msg: &pt,
aad: aad.as_bytes(),
@@ -113,13 +120,14 @@ pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Res
m = ARGON_M_COST_KIB,
t = ARGON_T_COST,
p = ARGON_P,
salt = B64.encode(salt.as_str().as_bytes()),
salt = B64.encode(salt),
nonce = B64.encode(nonce_bytes),
ct = B64.encode(&ct),
);
drop(cipher);
let _ = key;
key.zeroize();
salt.zeroize();
nonce_bytes.zeroize();
Ok(env)
@@ -130,6 +138,9 @@ pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Res
/// Returns the original plaintext on success or an error if the password is
/// wrong, the envelope was tampered with, or the input is malformed.
///
/// This function supports both the current format (with KDF params in AAD) and
/// the legacy format (without KDF params in AAD) for backwards compatibility.
///
/// Example
/// ```
/// use gman::{encrypt_string, decrypt_string};
@@ -143,6 +154,10 @@ pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Res
pub fn decrypt_string(password: impl Into<SecretString>, envelope: &str) -> Result<String> {
let password = password.into();
if password.expose_secret().is_empty() {
bail!("password cannot be empty");
}
let parts: Vec<&str> = envelope.split(';').collect();
if parts.len() < 7 {
bail!("invalid envelope format");
@@ -176,37 +191,66 @@ pub fn decrypt_string(password: impl Into<SecretString>, envelope: &str) -> Resu
let nonce_b64 = parts[5].strip_prefix("nonce=").context("missing nonce")?;
let ct_b64 = parts[6].strip_prefix("ct=").context("missing ct")?;
let salt_bytes = B64.decode(salt_b64).context("bad salt b64")?;
let mut nonce_bytes = B64.decode(nonce_b64).context("bad nonce b64")?;
let mut salt_bytes = B64.decode(salt_b64).context("bad salt b64")?;
let nonce_bytes = B64.decode(nonce_b64).context("bad nonce b64")?;
let mut ct = B64.decode(ct_b64).context("bad ct b64")?;
if nonce_bytes.len() != NONCE_LEN {
bail!("nonce length mismatch");
}
let key = derive_key(&password, &salt_bytes)?;
let mut key = derive_key(&password, &salt_bytes)?;
let cipher = XChaCha20Poly1305::new(&key);
let aad = format!("{};{}", HEADER, VERSION);
let nonce = XNonce::from_slice(&nonce_bytes);
let pt = cipher
.decrypt(
nonce,
chacha20poly1305::aead::Payload {
msg: &ct,
aad: aad.as_bytes(),
},
)
.map_err(|_| anyhow!("decryption failed (wrong password or corrupted data)"))?;
let aad_new = format!("{};{};{};m={},t={},p={}", HEADER, VERSION, KDF, m, t, p);
let aad_legacy = format!("{};{}", HEADER, VERSION);
nonce_bytes.zeroize();
let mut nonce_arr: [u8; NONCE_LEN] = nonce_bytes
.try_into()
.map_err(|_| anyhow!("invalid nonce length"))?;
let nonce: XNonce = nonce_arr.into();
let decrypt_result = cipher.decrypt(
&nonce,
chacha20poly1305::aead::Payload {
msg: &ct,
aad: aad_new.as_bytes(),
},
);
let mut pt = match decrypt_result {
Ok(pt) => pt,
Err(_) => cipher
.decrypt(
&nonce,
chacha20poly1305::aead::Payload {
msg: &ct,
aad: aad_legacy.as_bytes(),
},
)
.map_err(|_| anyhow!("decryption failed (wrong password or corrupted data)"))?,
};
let s = String::from_utf8(pt.clone()).context("plaintext not valid UTF-8")?;
key.zeroize();
salt_bytes.zeroize();
nonce_arr.zeroize();
ct.zeroize();
pt.zeroize();
let s = String::from_utf8(pt).context("plaintext not valid UTF-8")?;
Ok(s)
}
pub(crate) fn calling_app_name() -> String {
let exe: PathBuf = std::env::current_exe().expect("unable to get current exe path");
exe.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_owned())
.expect("executable name not valid UTF-8")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -237,12 +281,10 @@ mod tests {
}
#[test]
fn empty_password() {
fn empty_password_rejected() {
let pw = SecretString::new("".into());
let msg = "hello";
let env = encrypt_string(pw.clone(), msg).unwrap();
let out = decrypt_string(pw, &env).unwrap();
assert_eq!(msg, out);
assert!(encrypt_string(pw.clone(), msg).is_err());
}
#[test]
@@ -264,7 +306,7 @@ mod tests {
let mut ct = base64::engine::general_purpose::STANDARD
.decode(ct_b64)
.unwrap();
ct[0] ^= 0x01; // Flip a bit
ct[0] ^= 0x01;
let new_ct_b64 = base64::engine::general_purpose::STANDARD.encode(&ct);
let new_ct_part = format!("ct={}", new_ct_b64);
parts[6] = &new_ct_part;
+8 -14
View File
@@ -1,11 +1,13 @@
use crate::providers::SecretProvider;
use anyhow::{Context, Result};
use azure_identity::DefaultAzureCredential;
use azure_core::credentials::TokenCredential;
use azure_identity::DeveloperToolsCredential;
use azure_security_keyvault_secrets::models::SetSecretParameters;
use azure_security_keyvault_secrets::{ResourceExt, SecretClient};
use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use std::sync::Arc;
use validator::Validate;
#[skip_serializing_none]
@@ -40,12 +42,8 @@ impl SecretProvider for AzureKeyVaultProvider {
}
async fn get_secret(&self, key: &str) -> Result<String> {
let body = self
.get_client()?
.get_secret(key, "", None)
.await?
.into_body()
.await?;
let response = self.get_client()?.get_secret(key, None).await?;
let body = response.into_model()?;
body.value
.with_context(|| format!("Secret '{}' not found", key))
@@ -60,8 +58,7 @@ impl SecretProvider for AzureKeyVaultProvider {
self.get_client()?
.set_secret(key, params.try_into()?, None)
.await?
.into_body()
.await?;
.into_model()?;
Ok(())
}
@@ -77,10 +74,7 @@ impl SecretProvider for AzureKeyVaultProvider {
}
async fn list_secrets(&self) -> Result<Vec<String>> {
let mut pager = self
.get_client()?
.list_secret_properties(None)?
.into_stream();
let mut pager = self.get_client()?.list_secret_properties(None)?;
let mut secrets = Vec::new();
while let Some(props) = pager.try_next().await? {
let name = props.resource_id()?.name;
@@ -93,7 +87,7 @@ impl SecretProvider for AzureKeyVaultProvider {
impl AzureKeyVaultProvider {
fn get_client(&self) -> Result<SecretClient> {
let credential = DefaultAzureCredential::new()?;
let credential: Arc<dyn TokenCredential> = DeveloperToolsCredential::new(None)?;
let client = SecretClient::new(
format!(
"https://{}.vault.azure.net",
+3 -2
View File
@@ -1,3 +1,4 @@
use crate::calling_app_name;
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use dialoguer::Confirm;
@@ -25,7 +26,7 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
opts.validate()
.with_context(|| "invalid git sync options")?;
let commit_message = format!("chore: sync @ {}", Utc::now().to_rfc3339());
let config_dir = confy::get_configuration_file_path("gman", "vault")
let config_dir = confy::get_configuration_file_path(&calling_app_name(), "vault")
.with_context(|| "get config dir")?
.parent()
.map(Path::to_path_buf)
@@ -37,7 +38,7 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?;
// 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("gman", "vault")
let default_vault = confy::get_configuration_file_path(&calling_app_name(), "vault")
.with_context(|| "get default vault path")?;
let repo_vault = repo_dir.join("vault.yml");
if default_vault.exists() && !repo_vault.exists() {
+291 -40
View File
@@ -13,6 +13,7 @@ use crate::providers::git_sync::{
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 argon2::{Algorithm, Argon2, Params, Version};
@@ -63,8 +64,13 @@ pub struct LocalProvider {
impl Default for LocalProvider {
fn default() -> Self {
let password_file = match Config::local_provider_password_file() {
p if p.exists() => Some(p),
_ => None,
};
Self {
password_file: Config::local_provider_password_file(),
password_file,
git_branch: Some("main".into()),
git_remote_url: None,
git_user_name: None,
@@ -286,7 +292,7 @@ impl LocalProvider {
let s = serde_yaml::to_string(&cfg)?;
fs::write(&path, s).with_context(|| format!("failed to write {}", path.display()))?;
} else {
confy::store("gman", "config", &cfg)
confy::store(&calling_app_name(), "config", &cfg)
.with_context(|| "failed to save updated config via confy")?;
}
@@ -315,6 +321,22 @@ impl LocalProvider {
fn get_password(&self) -> Result<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 mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
bail!(
"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))?
@@ -335,10 +357,11 @@ fn default_vault_path() -> Result<PathBuf> {
let xdg_path = env::var_os("XDG_CONFIG_HOME").map(PathBuf::from);
if let Some(xdg) = xdg_path {
return Ok(xdg.join("gman").join("vault.yml"));
return Ok(xdg.join(calling_app_name()).join("vault.yml"));
}
confy::get_configuration_file_path("gman", "vault").with_context(|| "get config dir")
confy::get_configuration_file_path(&calling_app_name(), "vault")
.with_context(|| "get config dir")
}
fn base_config_dir() -> Result<PathBuf> {
@@ -362,24 +385,41 @@ fn store_vault(path: &Path, map: &HashMap<String, String>) -> Result<()> {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let s = serde_yaml::to_string(map).with_context(|| "serialize vault")?;
fs::write(path, s).with_context(|| format!("write {}", path.display()))
fs::write(path, &s).with_context(|| format!("write {}", path.display()))?;
#[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()))?;
}
Ok(())
}
fn encrypt_string(password: &SecretString, plaintext: &str) -> Result<String> {
if password.expose_secret().is_empty() {
bail!("password cannot be empty");
}
let mut salt = [0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt);
let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes);
let key = derive_key(password, &salt)?;
let mut key = derive_key(password, &salt)?;
let cipher = XChaCha20Poly1305::new(&key);
let aad = format!("{};{}", HEADER, VERSION);
let nonce = XNonce::from_slice(&nonce_bytes);
let aad = format!(
"{};{};{};m={},t={},p={}",
HEADER, VERSION, KDF, ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P
);
let nonce: XNonce = nonce_bytes.into();
let mut pt = plaintext.as_bytes().to_vec();
let ct = cipher
.encrypt(
nonce,
&nonce,
chacha20poly1305::aead::Payload {
msg: &pt,
aad: aad.as_bytes(),
@@ -402,6 +442,7 @@ fn encrypt_string(password: &SecretString, plaintext: &str) -> Result<String> {
);
drop(cipher);
key.zeroize();
salt.zeroize();
nonce_bytes.zeroize();
@@ -422,16 +463,30 @@ fn derive_key_with_params(
argon
.hash_password_into(password.expose_secret().as_bytes(), salt, &mut key_bytes)
.map_err(|e| anyhow!("argon2 derive error: {:?}", e))?;
let key: Key = key_bytes.into();
key_bytes.zeroize();
let key = Key::from_slice(&key_bytes);
Ok(*key)
Ok(key)
}
fn derive_key(password: &SecretString, salt: &[u8]) -> Result<Key> {
derive_key_with_params(password, salt, ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P)
}
fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> {
/// Attempts to decrypt with the given cipher, nonce, ciphertext, and AAD.
fn try_decrypt(
cipher: &XChaCha20Poly1305,
nonce: &XNonce,
ct: &[u8],
aad: &[u8],
) -> std::result::Result<Vec<u8>, chacha20poly1305::aead::Error> {
cipher.decrypt(nonce, chacha20poly1305::aead::Payload { msg: ct, aad })
}
type EnvelopeComponents = (u32, u32, u32, Vec<u8>, [u8; NONCE_LEN], Vec<u8>);
/// 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> {
let parts: Vec<&str> = envelope.trim().split(';').collect();
if parts.len() < 7 {
debug!("Invalid envelope format: {:?}", parts);
@@ -473,40 +528,202 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> {
.with_context(|| "missing nonce")?;
let ct_b64 = parts[6].strip_prefix("ct=").with_context(|| "missing ct")?;
let mut salt = B64.decode(salt_b64).with_context(|| "bad salt b64")?;
let mut nonce_bytes = B64.decode(nonce_b64).with_context(|| "bad nonce b64")?;
let mut ct = B64.decode(ct_b64).with_context(|| "bad ct b64")?;
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")?;
if salt.len() != SALT_LEN || nonce_bytes.len() != NONCE_LEN {
debug!(
"Salt/nonce length mismatch: salt {}, nonce {}",
salt.len(),
nonce_bytes.len()
);
bail!("salt/nonce length mismatch");
if nonce_bytes.len() != NONCE_LEN {
debug!("Nonce length mismatch: {}", nonce_bytes.len());
bail!("nonce length mismatch");
}
let key = derive_key_with_params(password, &salt, m, t, p)?;
let nonce_arr: [u8; NONCE_LEN] = nonce_bytes
.try_into()
.map_err(|_| anyhow!("invalid nonce length"))?;
Ok((m, t, p, salt, nonce_arr, ct))
}
fn decrypt_string(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_current = format!("{};{};{};m={},t={},p={}", HEADER, VERSION, KDF, m, t, p);
let mut key = derive_key_with_params(password, &salt, m, t, p)?;
let cipher = XChaCha20Poly1305::new(&key);
let aad = format!("{};{}", HEADER, VERSION);
let nonce = XNonce::from_slice(&nonce_bytes);
let pt = cipher
.decrypt(
nonce,
chacha20poly1305::aead::Payload {
msg: &ct,
aad: aad.as_bytes(),
},
)
.map_err(|_| anyhow!("decryption failed (wrong password or corrupted data)"))?;
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")?;
key.zeroize();
salt.zeroize();
nonce_arr.zeroize();
ct.zeroize();
return Ok(s);
}
key.zeroize();
salt.zeroize();
nonce_bytes.zeroize();
nonce_arr.zeroize();
ct.zeroize();
let s = String::from_utf8(pt).with_context(|| "plaintext not valid UTF-8")?;
Ok(s)
// 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,
})
}
}
#[cfg(test)]
@@ -522,7 +739,7 @@ mod tests {
let password = SecretString::new("test_password".to_string().into());
let salt = [0u8; 16];
let key = derive_key(&password, &salt).unwrap();
assert_eq!(key.as_slice().len(), 32);
assert_eq!(key.len(), 32);
}
#[test]
@@ -530,7 +747,7 @@ mod tests {
let password = SecretString::new("test_password".to_string().into());
let salt = [0u8; 16];
let key = derive_key_with_params(&password, &salt, 10, 1, 1).unwrap();
assert_eq!(key.as_slice().len(), 32);
assert_eq!(key.len(), 32);
}
#[test]
@@ -543,6 +760,40 @@ mod tests {
}
#[test]
#[cfg(unix)]
fn get_password_reads_password_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let file = dir.path().join("pw.txt");
fs::write(&file, "secretpw\n").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap();
let provider = LocalProvider {
password_file: Some(file),
runtime_provider_name: None,
..LocalProvider::default()
};
let pw = provider.get_password().unwrap();
assert_eq!(pw.expose_secret(), "secretpw");
}
#[test]
#[cfg(unix)]
fn get_password_rejects_insecure_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let file = dir.path().join("pw.txt");
fs::write(&file, "secretpw\n").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
let provider = LocalProvider {
password_file: Some(file),
runtime_provider_name: None,
..LocalProvider::default()
};
assert!(provider.get_password().is_err());
}
#[test]
#[cfg(not(unix))]
fn get_password_reads_password_file() {
let dir = tempdir().unwrap();
let file = dir.path().join("pw.txt");
@@ -560,7 +811,7 @@ mod tests {
fn persist_only_target_local_provider_git_settings() {
let td = tempdir().unwrap();
let xdg = td.path().join("xdg");
let app_dir = xdg.join("gman");
let app_dir = xdg.join(calling_app_name());
fs::create_dir_all(&app_dir).unwrap();
unsafe {
std_env::set_var("XDG_CONFIG_HOME", &xdg);
+73 -31
View File
@@ -1,3 +1,8 @@
//! CLI integration tests that execute the gman binary.
//!
//! These tests are skipped when cross-compiling because the compiled binary
//! cannot be executed on a different architecture (e.g., ARM64 binary on x86_64 host).
use assert_cmd::prelude::*;
use predicates::prelude::*;
use std::fs;
@@ -7,6 +12,20 @@ use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::TempDir;
fn gman_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_gman"))
}
/// Check if the gman binary can be executed on this system.
/// Returns false when cross-compiling (e.g., ARM64 binary on x86_64 host).
fn can_execute_binary() -> bool {
Command::new(gman_bin())
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn setup_env() -> (TempDir, PathBuf, PathBuf) {
let td = tempfile::tempdir().expect("tempdir");
let cfg_home = td.path().join("config");
@@ -46,27 +65,38 @@ providers:
password_file.display()
)
};
// Confy with yaml feature typically uses .yml; write both to be safe.
fs::write(app_dir.join("config.yml"), &cfg).unwrap();
fs::write(app_dir.join("config.yaml"), &cfg).unwrap();
}
fn create_password_file(path: &Path, content: &[u8]) {
fs::write(path, content).unwrap();
#[cfg(unix)]
{
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
}
}
#[test]
#[cfg(unix)]
fn cli_config_no_changes() {
if !can_execute_binary() {
eprintln!("Skipping test: cannot execute cross-compiled binary");
return;
}
let (td, xdg_cfg, xdg_cache) = setup_env();
let pw_file = td.path().join("pw.txt");
fs::write(&pw_file, b"pw\n").unwrap();
create_password_file(&pw_file, b"pw\n");
write_yaml_config(&xdg_cfg, &pw_file, None);
// Create a no-op editor script that exits successfully without modifying the file
let editor = td.path().join("noop-editor.sh");
fs::write(&editor, b"#!/bin/sh\nexit 0\n").unwrap();
let mut perms = fs::metadata(&editor).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&editor, perms).unwrap();
let mut cmd = Command::cargo_bin("gman").unwrap();
let mut cmd = Command::new(gman_bin());
cmd.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.env("EDITOR", &editor)
@@ -80,15 +110,23 @@ fn cli_config_no_changes() {
#[test]
#[cfg(unix)]
fn cli_config_updates_and_persists() {
if !can_execute_binary() {
eprintln!("Skipping test: cannot execute cross-compiled binary");
return;
}
let (td, xdg_cfg, xdg_cache) = setup_env();
let pw_file = td.path().join("pw.txt");
fs::write(&pw_file, b"pw\n").unwrap();
create_password_file(&pw_file, b"pw\n");
write_yaml_config(&xdg_cfg, &pw_file, None);
// Editor script appends a valid run_configs section to the YAML file
let editor = td.path().join("append-run-config.sh");
// Note: We need a small sleep to ensure the file modification timestamp changes.
// The dialoguer Editor uses file modification time to detect changes, and on fast
// systems the edit can complete within the same timestamp granularity.
let script = r#"#!/bin/sh
FILE="$1"
sleep 0.1
cat >> "$FILE" <<'EOF'
run_configs:
- name: echo
@@ -101,7 +139,7 @@ exit 0
perms.set_mode(0o755);
fs::set_permissions(&editor, perms).unwrap();
let mut cmd = Command::cargo_bin("gman").unwrap();
let mut cmd = Command::new(gman_bin());
cmd.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.env("EDITOR", &editor)
@@ -111,7 +149,6 @@ exit 0
"Configuration updated successfully",
));
// Verify that the config file now contains the run_configs key
let cfg_path = xdg_cfg.join("gman").join("config.yml");
let written = fs::read_to_string(&cfg_path).expect("config file readable");
assert!(written.contains("run_configs:"));
@@ -120,8 +157,13 @@ exit 0
#[test]
fn cli_shows_help() {
if !can_execute_binary() {
eprintln!("Skipping test: cannot execute cross-compiled binary");
return;
}
let (_td, cfg, cache) = setup_env();
let mut cmd = Command::cargo_bin("gman").unwrap();
let mut cmd = Command::new(gman_bin());
cmd.env("XDG_CACHE_HOME", &cache)
.env("XDG_CONFIG_HOME", &cfg)
.arg("--help");
@@ -132,13 +174,17 @@ fn cli_shows_help() {
#[test]
fn cli_add_get_list_update_delete_roundtrip() {
if !can_execute_binary() {
eprintln!("Skipping test: cannot execute cross-compiled binary");
return;
}
let (td, xdg_cfg, xdg_cache) = setup_env();
let pw_file = td.path().join("pw.txt");
fs::write(&pw_file, b"testpw\n").unwrap();
create_password_file(&pw_file, b"testpw\n");
write_yaml_config(&xdg_cfg, &pw_file, None);
// add
let mut add = Command::cargo_bin("gman").unwrap();
let mut add = Command::new(gman_bin());
add.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.stdin(Stdio::piped())
@@ -154,8 +200,7 @@ fn cli_add_get_list_update_delete_roundtrip() {
let add_out = child.wait_with_output().unwrap();
assert!(add_out.status.success());
// get (text)
let mut get = Command::cargo_bin("gman").unwrap();
let mut get = Command::new(gman_bin());
get.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.args(["get", "my_api_key"]);
@@ -163,8 +208,7 @@ fn cli_add_get_list_update_delete_roundtrip() {
.success()
.stdout(predicate::str::contains("super_secret"));
// get as JSON
let mut get_json = Command::cargo_bin("gman").unwrap();
let mut get_json = Command::new(gman_bin());
get_json
.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
@@ -173,8 +217,7 @@ fn cli_add_get_list_update_delete_roundtrip() {
predicate::str::contains("my_api_key").and(predicate::str::contains("super_secret")),
);
// list
let mut list = Command::cargo_bin("gman").unwrap();
let mut list = Command::new(gman_bin());
list.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.arg("list");
@@ -182,8 +225,7 @@ fn cli_add_get_list_update_delete_roundtrip() {
.success()
.stdout(predicate::str::contains("my_api_key"));
// update
let mut update = Command::cargo_bin("gman").unwrap();
let mut update = Command::new(gman_bin());
update
.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
@@ -199,8 +241,7 @@ fn cli_add_get_list_update_delete_roundtrip() {
let upd_out = child.wait_with_output().unwrap();
assert!(upd_out.status.success());
// get again
let mut get2 = Command::cargo_bin("gman").unwrap();
let mut get2 = Command::new(gman_bin());
get2.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.args(["get", "my_api_key"]);
@@ -208,15 +249,13 @@ fn cli_add_get_list_update_delete_roundtrip() {
.success()
.stdout(predicate::str::contains("new_val"));
// delete
let mut del = Command::cargo_bin("gman").unwrap();
let mut del = Command::new(gman_bin());
del.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.args(["delete", "my_api_key"]);
del.assert().success();
// get should now fail
let mut get_missing = Command::cargo_bin("gman").unwrap();
let mut get_missing = Command::new(gman_bin());
get_missing
.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
@@ -226,13 +265,17 @@ fn cli_add_get_list_update_delete_roundtrip() {
#[test]
fn cli_wrap_dry_run_env_injection() {
if !can_execute_binary() {
eprintln!("Skipping test: cannot execute cross-compiled binary");
return;
}
let (td, xdg_cfg, xdg_cache) = setup_env();
let pw_file = td.path().join("pw.txt");
fs::write(&pw_file, b"pw\n").unwrap();
create_password_file(&pw_file, b"pw\n");
write_yaml_config(&xdg_cfg, &pw_file, Some("echo"));
// Add the secret so the profile can read it
let mut add = Command::cargo_bin("gman").unwrap();
let mut add = Command::new(gman_bin());
add.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.stdin(Stdio::piped())
@@ -243,8 +286,7 @@ fn cli_wrap_dry_run_env_injection() {
let add_out = child.wait_with_output().unwrap();
assert!(add_out.status.success());
// Dry-run wrapping: prints preview command
let mut wrap = Command::cargo_bin("gman").unwrap();
let mut wrap = Command::new(gman_bin());
wrap.env("XDG_CONFIG_HOME", &xdg_cfg)
.env("XDG_CACHE_HOME", &xdg_cache)
.arg("--dry-run")
+8 -10
View File
@@ -252,16 +252,14 @@ mod tests {
#[test]
fn test_config_local_provider_password_file() {
let path = Config::local_provider_password_file();
let expected_path = dirs::home_dir().map(|p| p.join(".gman_password"));
if let Some(p) = &expected_path {
if !p.exists() {
assert_eq!(path, None);
} else {
assert_eq!(path, expected_path);
}
} else {
assert_eq!(path, None);
}
// Derive expected filename based on current test executable name
let exe = std::env::current_exe().expect("current_exe");
let stem = exe
.file_stem()
.and_then(|s| s.to_str())
.expect("utf-8 file stem");
let expected = dirs::home_dir().map(|p| p.join(format!(".{}_password", stem)));
assert_eq!(Some(path), expected);
}
#[test]
+8
View File
@@ -0,0 +1,8 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 155469a45d7311cd4003e23a3bcdaa8e55879e6222c1b6313a2b1f0b563bb195 # shrinks to password = "", msg = " "
cc 0bc9f608677234c082d10ff51b15dc39b4c194cdf920b4d87e553467c93824ed # shrinks to password = "", msg = ""
+6 -7
View File
@@ -1,15 +1,15 @@
use base64::Engine;
use gman::{decrypt_string, encrypt_string};
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
}
use secrecy::SecretString;
proptest! {
// Reduced case count because Argon2 key derivation is intentionally slow
// (65 MiB memory, 3 iterations per encryption/decryption)
#![proptest_config(ProptestConfig::with_cases(4))]
#[test]
fn prop_encrypt_decrypt_roundtrip(password in ".{0,64}", msg in ".{0,512}") {
fn prop_encrypt_decrypt_roundtrip(password in ".{1,64}", msg in ".{0,512}") {
let pw = SecretString::new(password.into());
let env = encrypt_string(pw.clone(), &msg).unwrap();
let out = decrypt_string(pw, &env).unwrap();
@@ -18,10 +18,9 @@ proptest! {
}
#[test]
fn prop_tamper_ciphertext_detected(password in ".{0,32}", msg in ".{1,128}") {
fn prop_tamper_ciphertext_detected(password in ".{1,32}", msg in ".{1,128}") {
let pw = SecretString::new(password.into());
let env = encrypt_string(pw.clone(), &msg).unwrap();
// Flip a bit in the ct payload segment
let mut parts: Vec<&str> = env.split(';').collect();
let ct_b64 = parts[6].strip_prefix("ct=").unwrap();
let mut ct = base64::engine::general_purpose::STANDARD.decode(ct_b64).unwrap();
+5 -4
View File
@@ -58,10 +58,11 @@ fn test_local_provider_invalid_email() {
#[test]
fn test_local_provider_default() {
let provider = LocalProvider::default();
assert_eq!(
provider.password_file,
Config::local_provider_password_file()
);
let expected_pw = {
let p = Config::local_provider_password_file();
if p.exists() { Some(p) } else { None }
};
assert_eq!(provider.password_file, expected_pw);
assert_eq!(provider.git_branch, Some("main".into()));
assert_eq!(provider.git_remote_url, None);
assert_eq!(provider.git_user_name, None);