Added support for multiple providers and wrote additional regression tests. Also fixed a bug with local synchronization with remote Git repositories when the CLI was just installed but the remote repo already exists with stuff in it.
This commit is contained in:
+114
-12
@@ -1,6 +1,6 @@
|
||||
use crate::command::preview_command;
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use gman::config::{Config, RunConfig};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use gman::config::{Config, ProviderConfig, RunConfig};
|
||||
use gman::providers::SecretProvider;
|
||||
use heck::ToSnakeCase;
|
||||
use log::{debug, error};
|
||||
@@ -13,9 +13,11 @@ use std::process::Command;
|
||||
|
||||
const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}";
|
||||
const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}";
|
||||
|
||||
pub fn wrap_and_run_command(
|
||||
secrets_provider: Box<dyn SecretProvider>,
|
||||
config: &Config,
|
||||
provider_config: &ProviderConfig,
|
||||
tokens: Vec<OsString>,
|
||||
profile_name: Option<String>,
|
||||
dry_run: bool,
|
||||
@@ -30,15 +32,17 @@ pub fn wrap_and_run_command(
|
||||
.ok_or_else(|| anyhow!("failed to convert program name to string"))?
|
||||
};
|
||||
let run_config_opt = config.run_configs.as_ref().and_then(|configs| {
|
||||
configs.iter().filter(|c| c.name.is_some()).find(|c| {
|
||||
c.name.as_ref().expect("failed to unwrap run config name") == run_config_profile_name
|
||||
})
|
||||
configs
|
||||
.iter()
|
||||
.find(|c| c.name.as_deref() == Some(run_config_profile_name))
|
||||
});
|
||||
if let Some(run_cfg) = run_config_opt {
|
||||
let secrets_result = run_cfg
|
||||
.secrets
|
||||
.as_ref()
|
||||
.expect("no secrets configured for run profile")
|
||||
.ok_or_else(|| {
|
||||
anyhow!("No secrets configured for run profile '{run_config_profile_name}'")
|
||||
})?
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let secret_name = key.to_snake_case().to_uppercase();
|
||||
@@ -47,7 +51,7 @@ pub fn wrap_and_run_command(
|
||||
run_config_profile_name
|
||||
);
|
||||
secrets_provider
|
||||
.get_secret(config, key.to_snake_case().to_uppercase().as_str())
|
||||
.get_secret(provider_config, key.to_snake_case().to_uppercase().as_str())
|
||||
.ok()
|
||||
.map_or_else(
|
||||
|| {
|
||||
@@ -157,6 +161,7 @@ pub fn wrap_and_run_command(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_files_secret_injections(
|
||||
secrets: HashMap<String, String>,
|
||||
run_config: &RunConfig,
|
||||
@@ -189,22 +194,24 @@ fn generate_files_secret_injections(
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub fn run_cmd(cmd: &mut Command, dry_run: bool) -> Result<()> {
|
||||
if dry_run {
|
||||
eprintln!("Command to be executed: {}", preview_command(cmd));
|
||||
println!("Command to be executed: {}", preview_command(cmd));
|
||||
} else {
|
||||
cmd.status()
|
||||
.with_context(|| format!("failed to execute command '{:?}'", cmd))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_args(
|
||||
args: &[OsString],
|
||||
run_config: &RunConfig,
|
||||
secrets: HashMap<String, String>,
|
||||
dry_run: bool,
|
||||
) -> Result<Vec<OsString>> {
|
||||
let args = args.to_vec();
|
||||
let mut args = args.to_vec();
|
||||
let flag = run_config
|
||||
.flag
|
||||
.as_ref()
|
||||
@@ -216,7 +223,6 @@ pub fn parse_args(
|
||||
.arg_format
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("arg_format must be set if flag is set"))?;
|
||||
let mut args = args.to_vec();
|
||||
if flag_position > args.len() {
|
||||
secrets.iter().for_each(|(k, v)| {
|
||||
let v = if dry_run { "*****" } else { v };
|
||||
@@ -246,10 +252,31 @@ pub fn parse_args(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::generate_files_secret_injections;
|
||||
use gman::config::RunConfig;
|
||||
use gman::config::{Config, ProviderConfig, RunConfig};
|
||||
use pretty_assertions::{assert_eq, assert_str_eq};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
|
||||
struct DummyProvider;
|
||||
impl SecretProvider for DummyProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"Dummy"
|
||||
}
|
||||
fn get_secret(&self, _config: &ProviderConfig, key: &str) -> Result<String> {
|
||||
Ok(format!("{}_VAL", key))
|
||||
}
|
||||
fn set_secret(&self, _config: &ProviderConfig, _key: &str, _value: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn delete_secret(&self, _key: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn sync(&self, _config: &mut ProviderConfig) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_files_secret_injections() {
|
||||
@@ -257,7 +284,7 @@ mod tests {
|
||||
secrets.insert("SECRET1".to_string(), "value1".to_string());
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "{{secret1}}").unwrap();
|
||||
fs::write(&file_path, "{{secret1}}").unwrap();
|
||||
|
||||
let run_config = RunConfig {
|
||||
name: Some("test".to_string()),
|
||||
@@ -275,4 +302,79 @@ mod tests {
|
||||
assert_str_eq!(result[0].1, "{{secret1}}");
|
||||
assert_str_eq!(result[0].2, "value1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_args_insert_and_append() {
|
||||
let run_config = RunConfig {
|
||||
name: Some("docker".into()),
|
||||
secrets: Some(vec!["api_key".into()]),
|
||||
files: None,
|
||||
flag: Some("-e".into()),
|
||||
flag_position: Some(1),
|
||||
arg_format: Some("{{key}}={{value}}".into()),
|
||||
};
|
||||
let mut secrets = HashMap::new();
|
||||
secrets.insert("API_KEY".into(), "xyz".into());
|
||||
|
||||
// Insert at position
|
||||
let args = vec![OsString::from("run"), OsString::from("image")];
|
||||
let out = parse_args(&args, &run_config, secrets.clone(), true).unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec!["run", "-e", "API_KEY=*****", "image"]
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// Append when position beyond len
|
||||
let run_config2 = RunConfig {
|
||||
flag_position: Some(99),
|
||||
..run_config.clone()
|
||||
};
|
||||
let out2 = parse_args(&args, &run_config2, secrets, true).unwrap();
|
||||
assert_eq!(
|
||||
out2,
|
||||
vec!["run", "image", "-e", "API_KEY=*****"]
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_and_run_command_no_profile() {
|
||||
let cfg = Config::default();
|
||||
let provider_cfg = ProviderConfig::default();
|
||||
let prov: Box<dyn SecretProvider> = Box::new(DummyProvider);
|
||||
let tokens = vec![OsString::from("echo"), OsString::from("hi")];
|
||||
let err = wrap_and_run_command(prov, &cfg, &provider_cfg, tokens, None, true).unwrap_err();
|
||||
assert!(err.to_string().contains("No run profile found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_and_run_command_env_injection_dry_run() {
|
||||
// Create a config with a matching run profile for command "echo"
|
||||
let run_cfg = RunConfig {
|
||||
name: Some("echo".into()),
|
||||
secrets: Some(vec!["api_key".into()]),
|
||||
files: None,
|
||||
flag: None,
|
||||
flag_position: None,
|
||||
arg_format: None,
|
||||
};
|
||||
let cfg = Config {
|
||||
run_configs: Some(vec![run_cfg]),
|
||||
..Config::default()
|
||||
};
|
||||
let provider_cfg = ProviderConfig::default();
|
||||
let prov: Box<dyn SecretProvider> = Box::new(DummyProvider);
|
||||
|
||||
// Capture stderr for dry_run preview
|
||||
let tokens = vec![OsString::from("echo"), OsString::from("hello")];
|
||||
// Best-effort: ensure function does not error under dry_run
|
||||
let res = wrap_and_run_command(prov, &cfg, &provider_cfg, tokens, None, true);
|
||||
assert!(res.is_ok());
|
||||
// Not asserting output text to keep test platform-agnostic
|
||||
}
|
||||
}
|
||||
|
||||
+20
-44
@@ -1,22 +1,19 @@
|
||||
use clap::{
|
||||
CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version,
|
||||
crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser, ValueEnum,
|
||||
};
|
||||
use std::ffi::OsString;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Subcommand;
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode};
|
||||
use gman::config::Config;
|
||||
use gman::providers::SupportedProvider;
|
||||
use gman::providers::local::LocalProvider;
|
||||
use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen};
|
||||
use gman::config::load_config;
|
||||
use heck::ToSnakeCase;
|
||||
use std::io::{self, IsTerminal, Read, Write};
|
||||
use std::panic::PanicHookInfo;
|
||||
|
||||
use crate::cli::wrap_and_run_command;
|
||||
use std::panic;
|
||||
use validator::Validate;
|
||||
|
||||
mod cli;
|
||||
mod command;
|
||||
@@ -28,20 +25,6 @@ enum OutputFormat {
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum)]
|
||||
#[clap(rename_all = "lower")]
|
||||
pub enum ProviderKind {
|
||||
Local,
|
||||
}
|
||||
|
||||
impl From<ProviderKind> for SupportedProvider {
|
||||
fn from(k: ProviderKind) -> Self {
|
||||
match k {
|
||||
ProviderKind::Local => SupportedProvider::Local(LocalProvider),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = crate_name!(),
|
||||
@@ -61,9 +44,9 @@ struct Cli {
|
||||
#[arg(short, long, value_enum)]
|
||||
output: Option<OutputFormat>,
|
||||
|
||||
/// Specify the secret provider to use (defaults to 'provider' in config or 'local')
|
||||
/// Specify the secret provider to use (defaults to 'default_provider' in config (usually 'local'))
|
||||
#[arg(long, value_enum)]
|
||||
provider: Option<ProviderKind>,
|
||||
provider: Option<String>,
|
||||
|
||||
/// Specify a run profile to use when wrapping a command
|
||||
#[arg(long, short)]
|
||||
@@ -130,8 +113,9 @@ fn main() -> Result<()> {
|
||||
panic_hook(info);
|
||||
}));
|
||||
let cli = Cli::parse();
|
||||
let mut config = load_config(&cli)?;
|
||||
let secrets_provider = config.extract_provider();
|
||||
let config = load_config()?;
|
||||
let mut provider_config = config.extract_provider_config(cli.provider.clone())?;
|
||||
let secrets_provider = provider_config.extract_provider();
|
||||
|
||||
match cli.command {
|
||||
Commands::Add { name } => {
|
||||
@@ -139,7 +123,7 @@ fn main() -> Result<()> {
|
||||
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
|
||||
let snake_case_name = name.to_snake_case().to_uppercase();
|
||||
secrets_provider
|
||||
.set_secret(&config, &snake_case_name, plaintext.trim_end())
|
||||
.set_secret(&provider_config, &snake_case_name, plaintext.trim_end())
|
||||
.map(|_| match cli.output {
|
||||
Some(_) => (),
|
||||
None => println!("✓ Secret '{snake_case_name}' added to the vault."),
|
||||
@@ -148,7 +132,7 @@ fn main() -> Result<()> {
|
||||
Commands::Get { name } => {
|
||||
let snake_case_name = name.to_snake_case().to_uppercase();
|
||||
secrets_provider
|
||||
.get_secret(&config, &snake_case_name)
|
||||
.get_secret(&provider_config, &snake_case_name)
|
||||
.map(|secret| match cli.output {
|
||||
Some(OutputFormat::Json) => {
|
||||
let json_output = serde_json::json!({
|
||||
@@ -170,7 +154,7 @@ fn main() -> Result<()> {
|
||||
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
|
||||
let snake_case_name = name.to_snake_case().to_uppercase();
|
||||
secrets_provider
|
||||
.update_secret(&config, &snake_case_name, plaintext.trim_end())
|
||||
.update_secret(&provider_config, &snake_case_name, plaintext.trim_end())
|
||||
.map(|_| match cli.output {
|
||||
Some(_) => (),
|
||||
None => println!("✓ Secret '{snake_case_name}' updated in the vault."),
|
||||
@@ -211,14 +195,21 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
Commands::Sync {} => {
|
||||
secrets_provider.sync(&mut config).map(|_| {
|
||||
secrets_provider.sync(&mut provider_config).map(|_| {
|
||||
if cli.output.is_none() {
|
||||
println!("✓ Secrets synchronized with remote")
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Commands::External(tokens) => {
|
||||
wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run)?;
|
||||
wrap_and_run_command(
|
||||
secrets_provider,
|
||||
&config,
|
||||
&provider_config,
|
||||
tokens,
|
||||
cli.profile,
|
||||
cli.dry_run,
|
||||
)?;
|
||||
}
|
||||
Commands::Completions { shell } => {
|
||||
let mut cmd = Cli::command();
|
||||
@@ -230,21 +221,6 @@ fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_config(cli: &Cli) -> Result<Config> {
|
||||
let mut config: Config = confy::load("gman", "config")?;
|
||||
config.validate()?;
|
||||
if let Some(local_password_file) = Config::local_provider_password_file() {
|
||||
config.password_file = Some(local_password_file);
|
||||
}
|
||||
|
||||
if let Some(provider_kind) = &cli.provider {
|
||||
let provider: SupportedProvider = provider_kind.clone().into();
|
||||
config.provider = provider;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn read_all_stdin() -> Result<String> {
|
||||
if io::stdin().is_terminal() {
|
||||
#[cfg(not(windows))]
|
||||
|
||||
+160
-16
@@ -1,12 +1,41 @@
|
||||
//! Application configuration and run-profile validation.
|
||||
//!
|
||||
//! The [`Config`] type captures global settings such as which secret provider
|
||||
//! to use and Git sync preferences. The [`RunConfig`] type describes how to
|
||||
//! inject secrets when wrapping a command.
|
||||
//!
|
||||
//! Example: validate a minimal run profile
|
||||
//! ```
|
||||
//! use gman::config::RunConfig;
|
||||
//! use validator::Validate;
|
||||
//!
|
||||
//! let rc = RunConfig{
|
||||
//! name: Some("echo".into()),
|
||||
//! secrets: Some(vec!["api_key".into()]),
|
||||
//! files: None,
|
||||
//! flag: None,
|
||||
//! flag_position: None,
|
||||
//! arg_format: None,
|
||||
//! };
|
||||
//! rc.validate().unwrap();
|
||||
//! ```
|
||||
use crate::providers::local::LocalProvider;
|
||||
use crate::providers::{SecretProvider, SupportedProvider};
|
||||
use anyhow::Result;
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::DisplayFromStr;
|
||||
use serde_with::serde_as;
|
||||
use serde_with::{skip_serializing_none, DisplayFromStr};
|
||||
use std::borrow::Cow;
|
||||
use std::path::PathBuf;
|
||||
use validator::{Validate, ValidationError};
|
||||
|
||||
#[skip_serializing_none]
|
||||
/// Describe how to inject secrets for a named command profile.
|
||||
///
|
||||
/// A valid profile either defines no flag/file settings or provides a complete
|
||||
/// set of `flag`, `flag_position`, and `arg_format`. Additionally, the flag
|
||||
/// mode and the file‑injection mode are mutually exclusive.
|
||||
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[validate(schema(function = "flags_or_none", skip_on_field_errors = false))]
|
||||
#[validate(schema(function = "flags_or_files"))]
|
||||
@@ -68,8 +97,24 @@ fn flags_or_files(run_config: &RunConfig) -> Result<(), ValidationError> {
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[skip_serializing_none]
|
||||
/// Configuration for a secret provider.
|
||||
///
|
||||
/// Example: create a local provider config and validate it
|
||||
/// ```
|
||||
/// use gman::config::ProviderConfig;
|
||||
/// use gman::providers::SupportedProvider;
|
||||
/// use gman::providers::local::LocalProvider;
|
||||
/// use validator::Validate;
|
||||
///
|
||||
/// let provider = SupportedProvider::Local(LocalProvider);
|
||||
/// let provider_config = ProviderConfig { provider, ..Default::default() };
|
||||
/// provider_config.validate().unwrap();
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Config {
|
||||
pub struct ProviderConfig {
|
||||
#[validate(required)]
|
||||
pub name: Option<String>,
|
||||
#[serde_as(as = "DisplayFromStr")]
|
||||
pub provider: SupportedProvider,
|
||||
pub password_file: Option<PathBuf>,
|
||||
@@ -79,26 +124,31 @@ pub struct Config {
|
||||
#[validate(email)]
|
||||
pub git_user_email: Option<String>,
|
||||
pub git_executable: Option<PathBuf>,
|
||||
#[validate(nested)]
|
||||
pub run_configs: Option<Vec<RunConfig>>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
impl Default for ProviderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: SupportedProvider::Local(Default::default()),
|
||||
name: Some("local".into()),
|
||||
provider: SupportedProvider::Local(LocalProvider),
|
||||
password_file: Config::local_provider_password_file(),
|
||||
git_branch: Some("main".into()),
|
||||
git_remote_url: None,
|
||||
git_user_name: None,
|
||||
git_user_email: None,
|
||||
git_executable: None,
|
||||
run_configs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
impl ProviderConfig {
|
||||
/// Instantiate the configured secret provider.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use gman::config::ProviderConfig;
|
||||
/// let provider = ProviderConfig::default().extract_provider();
|
||||
/// println!("using provider: {}", provider.name());
|
||||
/// ```
|
||||
pub fn extract_provider(&self) -> Box<dyn SecretProvider> {
|
||||
match &self.provider {
|
||||
SupportedProvider::Local(p) => {
|
||||
@@ -107,15 +157,109 @@ impl Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_provider_password_file() -> Option<PathBuf> {
|
||||
let mut path = dirs::home_dir().map(|p| p.join(".gman_password"));
|
||||
if let Some(p) = &path
|
||||
&& !p.exists()
|
||||
{
|
||||
path = None;
|
||||
#[serde_as]
|
||||
#[skip_serializing_none]
|
||||
/// Global configuration for the library and CLI.
|
||||
///
|
||||
/// Example: pick a provider and validate the configuration
|
||||
/// ```
|
||||
/// use gman::config::Config;
|
||||
/// use gman::config::ProviderConfig;
|
||||
/// use gman::providers::SupportedProvider;
|
||||
/// use gman::providers::local::LocalProvider;
|
||||
/// use validator::Validate;
|
||||
///
|
||||
/// let provider = SupportedProvider::Local(LocalProvider);
|
||||
/// let provider_config = ProviderConfig { provider, ..Default::default() };
|
||||
/// let cfg = Config{ providers: vec![provider_config], ..Default::default() };
|
||||
/// cfg.validate().unwrap();
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[validate(schema(function = "default_provider_exists"))]
|
||||
pub struct Config {
|
||||
pub default_provider: Option<String>,
|
||||
#[validate(length(min = 1))]
|
||||
#[validate(nested)]
|
||||
pub providers: Vec<ProviderConfig>,
|
||||
#[validate(nested)]
|
||||
pub run_configs: Option<Vec<RunConfig>>,
|
||||
}
|
||||
|
||||
fn default_provider_exists(config: &Config) -> Result<(), ValidationError> {
|
||||
if let Some(default) = &config.default_provider {
|
||||
if config.providers.iter().any(|p| p.name.as_deref() == Some(default)) {
|
||||
Ok(())
|
||||
} else {
|
||||
let mut err = ValidationError::new("default_provider_missing");
|
||||
err.message = Some(Cow::Borrowed(
|
||||
"The default_provider does not match any configured provider names",
|
||||
));
|
||||
Err(err)
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_provider: Some("local".into()),
|
||||
providers: vec![ProviderConfig::default()],
|
||||
run_configs: None,
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Instantiate the configured secret provider.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use gman::config::Config;
|
||||
/// let provider_config = Config::default().extract_provider_config(None);
|
||||
/// println!("using provider config: {}", provider_config.unwrap().name);
|
||||
/// ```
|
||||
pub fn extract_provider_config(&self, provider_name: Option<String>) -> Result<ProviderConfig> {
|
||||
let name = provider_name
|
||||
.or_else(|| self.default_provider.clone())
|
||||
.unwrap_or_else(|| "local".into());
|
||||
self.providers
|
||||
.iter()
|
||||
.find(|p| p.name.as_deref() == Some(&name))
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("No provider configuration found for '{}'", name))
|
||||
}
|
||||
|
||||
/// Discover the default password file for the local provider.
|
||||
///
|
||||
/// On most systems this resolves to `~/.gman_password` when the file
|
||||
/// exists, otherwise `None`.
|
||||
pub fn local_provider_password_file() -> Option<PathBuf> {
|
||||
let candidate = dirs::home_dir().map(|p| p.join(".gman_password"));
|
||||
match candidate {
|
||||
Some(p) if p.exists() => Some(p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_config() -> Result<Config> {
|
||||
let mut config: Config = confy::load("gman", "config")?;
|
||||
config.validate()?;
|
||||
|
||||
config
|
||||
.providers
|
||||
.iter_mut()
|
||||
.filter(|p| matches!(p.provider, SupportedProvider::Local(_)))
|
||||
.for_each(|p| {
|
||||
if p.password_file.is_none()
|
||||
&& let Some(local_password_file) = Config::local_provider_password_file()
|
||||
{
|
||||
p.password_file = Some(local_password_file);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
+60
-7
@@ -1,16 +1,40 @@
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
//! Gman core library
|
||||
//!
|
||||
//! This crate provides two layers:
|
||||
//! - A small crypto helper API for envelope encrypting/decrypting strings.
|
||||
//! - Public modules for configuration and secret providers used by the CLI.
|
||||
//!
|
||||
//! Quick start for the crypto helpers:
|
||||
//!
|
||||
//! ```
|
||||
//! use gman::{encrypt_string, decrypt_string};
|
||||
//! use secrecy::SecretString;
|
||||
//!
|
||||
//! let password = SecretString::new("correct horse battery staple".into());
|
||||
//! let ciphertext = encrypt_string(password.clone(), "swordfish").unwrap();
|
||||
//! let plaintext = decrypt_string(password, &ciphertext).unwrap();
|
||||
//!
|
||||
//! assert_eq!(plaintext, "swordfish");
|
||||
//! ```
|
||||
//!
|
||||
//! The `config` and `providers` modules power the CLI. They can be embedded
|
||||
//! in other programs, but many functions interact with the user or the
|
||||
//! filesystem. Prefer `no_run` doctests for those.
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use argon2::{
|
||||
password_hash::{rand_core::RngCore, SaltString},
|
||||
Algorithm, Argon2, Params, Version,
|
||||
password_hash::{SaltString, rand_core::RngCore},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use chacha20poly1305::{
|
||||
Key, XChaCha20Poly1305, XNonce,
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
Key, XChaCha20Poly1305, XNonce,
|
||||
};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use zeroize::Zeroize;
|
||||
/// Configuration structures and helpers used by the CLI and library.
|
||||
pub mod config;
|
||||
/// Secret provider trait and implementations.
|
||||
pub mod providers;
|
||||
|
||||
pub(crate) const HEADER: &str = "$VAULT";
|
||||
@@ -35,12 +59,26 @@ fn derive_key(password: &SecretString, salt: &[u8]) -> Result<Key> {
|
||||
.hash_password_into(password.expose_secret().as_bytes(), salt, &mut key_bytes)
|
||||
.map_err(|e| anyhow!("argon2 into error: {:?}", e))?;
|
||||
|
||||
let cloned_key_bytes = key_bytes;
|
||||
let key = Key::from_slice(&cloned_key_bytes);
|
||||
let key = *Key::from_slice(&key_bytes);
|
||||
key_bytes.zeroize();
|
||||
Ok(*key)
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypt a UTF‑8 string using a password and return a portable envelope.
|
||||
///
|
||||
/// The returned value is a semicolon‑separated envelope containing metadata
|
||||
/// (header, version, KDF params) and base64 encoded salt, nonce and
|
||||
/// ciphertext. It is safe to store in configuration files.
|
||||
///
|
||||
/// Example
|
||||
/// ```
|
||||
/// use gman::encrypt_string;
|
||||
/// use secrecy::SecretString;
|
||||
///
|
||||
/// let pw = SecretString::new("password".into());
|
||||
/// let env = encrypt_string(pw, "hello").unwrap();
|
||||
/// assert!(env.starts_with("$VAULT;v1;argon2id;"));
|
||||
/// ```
|
||||
pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Result<String> {
|
||||
let password = password.into();
|
||||
|
||||
@@ -87,6 +125,21 @@ pub fn encrypt_string(password: impl Into<SecretString>, plaintext: &str) -> Res
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
/// Decrypt an envelope produced by [`encrypt_string`].
|
||||
///
|
||||
/// Returns the original plaintext on success or an error if the password is
|
||||
/// wrong, the envelope was tampered with, or the input is malformed.
|
||||
///
|
||||
/// Example
|
||||
/// ```
|
||||
/// use gman::{encrypt_string, decrypt_string};
|
||||
/// use secrecy::SecretString;
|
||||
///
|
||||
/// let pw = SecretString::new("pw".into());
|
||||
/// let env = encrypt_string(pw.clone(), "top secret").unwrap();
|
||||
/// let pt = decrypt_string(pw, &env).unwrap();
|
||||
/// assert_eq!(pt, "top secret");
|
||||
/// ```
|
||||
pub fn decrypt_string(password: impl Into<SecretString>, envelope: &str) -> Result<String> {
|
||||
let password = password.into();
|
||||
|
||||
|
||||
+100
-9
@@ -66,10 +66,16 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
|
||||
checkout_branch(&git, &repo_dir, branch)?;
|
||||
set_origin(&git, &repo_dir, remote_url)?;
|
||||
|
||||
stage_all(&git, &repo_dir)?;
|
||||
|
||||
// Always align local with remote before staging/committing. For a fresh
|
||||
// repo where the remote already has content, we intentionally discard any
|
||||
// local working tree changes and take the remote state to avoid merge
|
||||
// conflicts on first sync.
|
||||
fetch_and_pull(&git, &repo_dir, branch)?;
|
||||
|
||||
// Stage and commit any subsequent local changes after aligning with remote
|
||||
// so we don't merge uncommitted local state.
|
||||
stage_all(&git, &repo_dir)?;
|
||||
|
||||
commit_now(&git, &repo_dir, &commit_message)?;
|
||||
|
||||
run_git(
|
||||
@@ -228,17 +234,51 @@ fn stage_all(git: &Path, repo: &Path) -> Result<()> {
|
||||
}
|
||||
|
||||
fn fetch_and_pull(git: &Path, repo: &Path, branch: &str) -> Result<()> {
|
||||
run_git(git, repo, &["fetch", "origin", branch])
|
||||
// Fetch all refs from origin (safe even if branch doesn't exist remotely)
|
||||
run_git(git, repo, &["fetch", "origin", "--prune"])
|
||||
.with_context(|| "Failed to fetch changes from remote")?;
|
||||
run_git(
|
||||
git,
|
||||
repo,
|
||||
&["merge", "--ff-only", &format!("origin/{branch}")],
|
||||
)
|
||||
.with_context(|| "Failed to merge remote changes")?;
|
||||
|
||||
let origin_ref = format!("origin/{branch}");
|
||||
let remote_has_branch = has_remote_branch(git, repo, branch);
|
||||
|
||||
// If the repo has no commits yet, prefer remote state and discard local
|
||||
// if the remote branch exists. Otherwise, keep local state and allow an
|
||||
// initial commit to be created and pushed.
|
||||
if !has_head(git, repo) {
|
||||
if remote_has_branch {
|
||||
run_git(git, repo, &["checkout", "-f", "-B", branch, &origin_ref])
|
||||
.with_context(|| "Failed to checkout remote branch over local state")?;
|
||||
run_git(git, repo, &["reset", "--hard", &origin_ref])
|
||||
.with_context(|| "Failed to hard reset to remote branch")?;
|
||||
run_git(git, repo, &["clean", "-fd"]).with_context(|| "Failed to clean untracked files")?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If we have local history and the remote branch exists, fast-forward.
|
||||
if remote_has_branch {
|
||||
run_git(
|
||||
git,
|
||||
repo,
|
||||
&["merge", "--ff-only", &origin_ref],
|
||||
)
|
||||
.with_context(|| "Failed to merge remote changes")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_remote_branch(git: &Path, repo: &Path, branch: &str) -> bool {
|
||||
Command::new(git)
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["show-ref", "--verify", "--quiet", &format!("refs/remotes/origin/{}", branch)])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn has_head(git: &Path, repo: &Path) -> bool {
|
||||
Command::new(git)
|
||||
.arg("-C")
|
||||
@@ -280,3 +320,54 @@ fn commit_now(git: &Path, repo: &Path, msg: &str) -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sync_opts_validation_ok() {
|
||||
let remote = Some("git@github.com:user/repo.git".to_string());
|
||||
let branch = Some("main".to_string());
|
||||
let opts = SyncOpts {
|
||||
remote_url: &remote,
|
||||
branch: &branch,
|
||||
user_name: &None,
|
||||
user_email: &None,
|
||||
git_executable: &None,
|
||||
};
|
||||
assert!(opts.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_opts_validation_missing_fields() {
|
||||
let remote = None;
|
||||
let branch = None;
|
||||
let opts = SyncOpts {
|
||||
remote_url: &remote,
|
||||
branch: &branch,
|
||||
user_name: &None,
|
||||
user_email: &None,
|
||||
git_executable: &None,
|
||||
};
|
||||
assert!(opts.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_git_prefers_override_and_env() {
|
||||
// Override path wins
|
||||
let override_path = Some(PathBuf::from("/custom/git"));
|
||||
let got = resolve_git(override_path.as_ref()).unwrap();
|
||||
assert_eq!(got, PathBuf::from("/custom/git"));
|
||||
|
||||
// If no override, env var is used
|
||||
unsafe {
|
||||
env::set_var("GIT_EXECUTABLE", "/env/git");
|
||||
}
|
||||
let got_env = resolve_git(None).unwrap();
|
||||
assert_eq!(got_env, PathBuf::from("/env/git"));
|
||||
unsafe {
|
||||
env::remove_var("GIT_EXECUTABLE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
-16
@@ -1,29 +1,30 @@
|
||||
use anyhow::{Context, anyhow, bail};
|
||||
use anyhow::{anyhow, bail, Context};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::ProviderConfig;
|
||||
use crate::providers::git_sync::{sync_and_push, SyncOpts};
|
||||
use crate::providers::SecretProvider;
|
||||
use crate::providers::git_sync::{SyncOpts, sync_and_push};
|
||||
use crate::{
|
||||
ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use chacha20poly1305::aead::rand_core::RngCore;
|
||||
use chacha20poly1305::{
|
||||
Key, XChaCha20Poly1305, XNonce,
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
Key, XChaCha20Poly1305, XNonce,
|
||||
};
|
||||
use dialoguer::{Input, theme};
|
||||
use dialoguer::{theme, Input};
|
||||
use log::{debug, error};
|
||||
use serde::Deserialize;
|
||||
use theme::ColorfulTheme;
|
||||
use validator::Validate;
|
||||
|
||||
/// Configuration for the local file-based provider.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalProviderConfig {
|
||||
pub vault_path: String,
|
||||
@@ -40,6 +41,25 @@ impl Default for LocalProviderConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// File-based vault provider with optional Git sync.
|
||||
///
|
||||
/// This provider stores encrypted envelopes in a per-user configuration
|
||||
/// directory via `confy`. A password is obtained from a configured password
|
||||
/// file or via an interactive prompt.
|
||||
///
|
||||
/// Example
|
||||
/// ```no_run
|
||||
/// use gman::providers::local::LocalProvider;
|
||||
/// use gman::providers::SecretProvider;
|
||||
/// use gman::config::Config;
|
||||
///
|
||||
/// let provider = LocalProvider;
|
||||
/// let cfg = Config::default();
|
||||
/// // Will prompt for a password when reading/writing secrets unless a
|
||||
/// // password file is configured.
|
||||
/// // provider.set_secret(&cfg, "MY_SECRET", "value")?;
|
||||
/// # Ok::<(), anyhow::Error>(())
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
|
||||
pub struct LocalProvider;
|
||||
|
||||
@@ -48,7 +68,7 @@ impl SecretProvider for LocalProvider {
|
||||
"LocalProvider"
|
||||
}
|
||||
|
||||
fn get_secret(&self, config: &Config, key: &str) -> Result<String> {
|
||||
fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result<String> {
|
||||
let vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
|
||||
let envelope = vault
|
||||
.get(key)
|
||||
@@ -61,7 +81,7 @@ impl SecretProvider for LocalProvider {
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
fn set_secret(&self, config: &Config, key: &str, value: &str) -> Result<()> {
|
||||
fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> {
|
||||
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
|
||||
if vault.contains_key(key) {
|
||||
error!(
|
||||
@@ -79,7 +99,7 @@ impl SecretProvider for LocalProvider {
|
||||
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault")
|
||||
}
|
||||
|
||||
fn update_secret(&self, config: &Config, key: &str, value: &str) -> Result<()> {
|
||||
fn update_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> {
|
||||
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
|
||||
|
||||
let password = get_password(config)?;
|
||||
@@ -119,7 +139,7 @@ impl SecretProvider for LocalProvider {
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn sync(&self, config: &mut Config) -> Result<()> {
|
||||
fn sync(&self, config: &mut ProviderConfig) -> Result<()> {
|
||||
let mut config_changed = false;
|
||||
|
||||
if config.git_branch.is_none() {
|
||||
@@ -139,9 +159,9 @@ impl SecretProvider for LocalProvider {
|
||||
let remote: String = Input::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt("Enter remote git URL to sync with")
|
||||
.validate_with(|s: &String| {
|
||||
Config {
|
||||
ProviderConfig {
|
||||
git_remote_url: Some(s.clone()),
|
||||
..Config::default()
|
||||
..ProviderConfig::default()
|
||||
}
|
||||
.validate()
|
||||
.map(|_| ())
|
||||
@@ -314,7 +334,7 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn get_password(config: &Config) -> Result<SecretString> {
|
||||
fn get_password(config: &ProviderConfig) -> Result<SecretString> {
|
||||
if let Some(password_file) = &config.password_file {
|
||||
let password = SecretString::new(
|
||||
fs::read_to_string(password_file)
|
||||
@@ -333,10 +353,10 @@ fn get_password(config: &Config) -> Result<SecretString> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::derive_key;
|
||||
use crate::providers::local::derive_key_with_params;
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use secrecy::SecretString;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_derive_key() {
|
||||
@@ -353,4 +373,26 @@ mod tests {
|
||||
let key = derive_key_with_params(&password, &salt, 10, 1, 1).unwrap();
|
||||
assert_eq!(key.as_slice().len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypto_roundtrip_local_impl() {
|
||||
let pw = SecretString::new("pw".into());
|
||||
let msg = "hello world";
|
||||
let env = encrypt_string(&pw, msg).unwrap();
|
||||
let out = decrypt_string(&pw, &env).unwrap();
|
||||
assert_eq!(out, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_password_reads_password_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("pw.txt");
|
||||
fs::write(&file, "secretpw\n").unwrap();
|
||||
let cfg = ProviderConfig {
|
||||
password_file: Some(file),
|
||||
..ProviderConfig::default()
|
||||
};
|
||||
let pw = get_password(&cfg).unwrap();
|
||||
assert_eq!(pw.expose_secret(), "secretpw");
|
||||
}
|
||||
}
|
||||
|
||||
+29
-6
@@ -1,19 +1,34 @@
|
||||
//! Secret provider trait and registry.
|
||||
//!
|
||||
//! Implementations provide storage/backends for secrets and a common
|
||||
//! interface used by the CLI.
|
||||
//!
|
||||
//! Selecting a provider from a string:
|
||||
//! ```
|
||||
//! use std::str::FromStr;
|
||||
//! use gman::providers::SupportedProvider;
|
||||
//!
|
||||
//! let p = SupportedProvider::from_str("local").unwrap();
|
||||
//! assert_eq!(p.to_string(), "local");
|
||||
//! ```
|
||||
mod git_sync;
|
||||
pub mod local;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::ProviderConfig;
|
||||
use crate::providers::local::LocalProvider;
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::Deserialize;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::str::FromStr;
|
||||
use thiserror::Error;
|
||||
|
||||
/// A secret storage backend capable of CRUD and sync, with optional
|
||||
/// update and listing
|
||||
pub trait SecretProvider {
|
||||
fn name(&self) -> &'static str;
|
||||
fn get_secret(&self, config: &Config, key: &str) -> Result<String>;
|
||||
fn set_secret(&self, config: &Config, key: &str, value: &str) -> Result<()>;
|
||||
fn update_secret(&self, _config: &Config, _key: &str, _value: &str) -> Result<()> {
|
||||
fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result<String>;
|
||||
fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()>;
|
||||
fn update_secret(&self, _config: &ProviderConfig, _key: &str, _value: &str) -> Result<()> {
|
||||
Err(anyhow!(
|
||||
"update secret not supported for provider {}",
|
||||
self.name()
|
||||
@@ -26,20 +41,28 @@ pub trait SecretProvider {
|
||||
self.name()
|
||||
))
|
||||
}
|
||||
fn sync(&self, config: &mut Config) -> Result<()>;
|
||||
fn sync(&self, config: &mut ProviderConfig) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Errors when parsing a provider identifier.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ParseProviderError {
|
||||
#[error("unsupported provider '{0}'")]
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
/// Registry of built-in providers.
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)]
|
||||
pub enum SupportedProvider {
|
||||
Local(LocalProvider),
|
||||
}
|
||||
|
||||
impl Default for SupportedProvider {
|
||||
fn default() -> Self {
|
||||
SupportedProvider::Local(LocalProvider)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SupportedProvider {
|
||||
type Err = ParseProviderError;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user