From 5623eb412827442e73b787472352f07034f505d1 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 9 Sep 2025 16:23:26 -0600 Subject: [PATCH] Added support for run profiles --- src/bin/gman/command.rs | 111 ++++++++++++++++++++++++ src/bin/gman/main.rs | 184 ++++++++++++++++++++++++++++++++++++++-- src/config.rs | 55 +++++++++++- 3 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 src/bin/gman/command.rs diff --git a/src/bin/gman/command.rs b/src/bin/gman/command.rs new file mode 100644 index 0000000..b8fcb57 --- /dev/null +++ b/src/bin/gman/command.rs @@ -0,0 +1,111 @@ +use std::borrow::Cow; +use std::ffi::{OsStr}; +use std::process::Command; + +pub fn preview_command(cmd: &Command) -> String { + #[cfg(unix)] + { + let mut parts: Vec = Vec::new(); + + for (k, vopt) in cmd.get_envs() { + match vopt { + Some(v) => parts.push(format!( + "{}={}", + sh_escape(k), + sh_escape(v), + )), + None => parts.push(format!("unset {}", sh_escape(k))), // env removed + } + } + + parts.push(sh_escape(cmd.get_program()).into_owned()); + parts.extend(cmd.get_args().map(|a| sh_escape(a).into_owned())); + parts.join(" ") + } + + #[cfg(windows)] + { + let mut parts: Vec = Vec::new(); + + // On Windows, emulate `cmd.exe` style env setting + // (This is for display; Command doesn’t invoke cmd.exe.) + let mut env_bits = Vec::new(); + for (k, vopt) in cmd.get_envs() { + match vopt { + Some(v) => env_bits.push(format!("set {}={}", ps_quote(k), ps_quote(v))), + None => env_bits.push(format!("set {}=", ps_quote(k))), // unset + } + } + if !env_bits.is_empty() { + parts.push(env_bits.join(" && ")); + parts.push("&&".to_owned()); + } + + // Program + args (quote per CreateProcess rules) + parts.push(win_quote(cmd.get_program())); + parts.extend(cmd.get_args().map(win_quote)); + parts.join(" ") + } +} + +#[cfg(unix)] +fn sh_escape(s: &OsStr) -> Cow<'_, str> { + let s = s.to_string_lossy(); + if s.is_empty() || s.chars().any(|c| c.is_whitespace() || "!\"#$&'()*;<>?`\\|[]{}".contains(c)) + { + let mut out = String::from("'"); + for ch in s.chars() { + if ch == '\'' { + out.push_str("'\\''"); + } else { + out.push(ch); + } + } + out.push('\''); + Cow::Owned(out) + } else { + Cow::Owned(s.into_owned()) + } +} + +#[cfg(windows)] +fn win_quote(s: &OsStr) -> String { + // Quote per Windows argv rules (CreateProcess / CommandLineToArgvW). + // Wrap in "..." and escape internal " with backslashes. + let s = s.to_string_lossy(); + if !s.contains([' ', '\t', '"', '\\']) { + return s.into_owned(); + } + let mut out = String::from("\""); + let mut backslashes = 0; + for ch in s.chars() { + match ch { + '\\' => backslashes += 1, + '"' => { + out.extend(std::iter::repeat('\\').take(backslashes * 2 + 1)); + out.push('"'); + backslashes = 0; + } + _ => { + out.extend(std::iter::repeat('\\').take(backslashes)); + backslashes = 0; + out.push(ch); + } + } + } + out.extend(std::iter::repeat('\\').take(backslashes)); + out.push('"'); + out +} + +// For displaying env names/values in the "set name=value" bits. +// Single-quote for PowerShell-like readability; fine for display purposes. +#[cfg(windows)] +fn ps_quote(s: &OsStr) -> String { + let s = s.to_string_lossy(); + if s.chars().any(|c| c.is_whitespace() || r#"'&|<>()^"%!;"#.contains(c)) { + format!("'{}'", s.replace('\'', "''")) + } else { + s.into_owned() + } +} diff --git a/src/bin/gman/main.rs b/src/bin/gman/main.rs index 44e6dff..2cc310f 100644 --- a/src/bin/gman/main.rs +++ b/src/bin/gman/main.rs @@ -1,22 +1,31 @@ use clap::{ CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version, }; +use std::collections::HashMap; +use std::ffi::OsString; -use anyhow::{Context, Result}; +use crate::command::preview_command; +use anyhow::{Context, Result, anyhow}; use clap::Subcommand; use crossterm::execute; use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode}; -use gman::config::Config; -use gman::providers::SupportedProvider; +use gman::config::{Config, RunConfig}; use gman::providers::local::LocalProvider; +use gman::providers::{SecretProvider, SupportedProvider}; use heck::ToSnakeCase; +use log::debug; use std::io::{self, IsTerminal, Read, Write}; use std::panic; use std::panic::PanicHookInfo; +use std::process::Command; use validator::Validate; +mod command; mod utils; +const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{key}"; +const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{value}"; + #[derive(Debug, Clone, ValueEnum)] enum OutputFormat { Text, @@ -60,11 +69,19 @@ struct Cli { #[arg(long, value_enum)] provider: Option, + /// Specify a run profile to use when wrapping a command + #[arg(long)] + profile: Option, + + /// Output the command that will be run instead of executing it + #[arg(long)] + dry_run: bool, + #[command(subcommand)] command: Commands, } -#[derive(Subcommand, Debug)] +#[derive(Subcommand, Clone, Debug)] enum Commands { /// Add a secret to the configured secret provider Add { @@ -91,12 +108,17 @@ enum Commands { }, /// List all secrets stored in the configured secret provider (if supported by the provider) - /// If a provider does not support listing secrets, this command will return an error. + /// If a provider does not support listing secrets, this command will return an error. List {}, /// Sync secrets with remote storage (if supported by the provider) Sync {}, + /// Wrap the provided command and supply it with secrets as environment variables or as + /// configured in a corresponding run profile + #[command(external_subcommand)] + External(Vec), + /// Generate shell completion scripts Completions { /// The shell to generate the script for @@ -200,6 +222,9 @@ fn main() -> Result<()> { Some(_) => (), })?; } + Commands::External(tokens) => { + wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run)?; + } Commands::Completions { shell } => { let mut cmd = Cli::command(); let bin_name = cmd.get_name().to_string(); @@ -225,6 +250,155 @@ fn load_config(cli: &Cli) -> Result { Ok(config) } +pub fn wrap_and_run_command( + secrets_provider: Box, + config: &Config, + tokens: Vec, + profile_name: Option, + dry_run: bool, +) -> Result<()> { + let (prog, args) = tokens + .split_first() + .with_context(|| "need a command to run")?; + let run_config_profile_name = if let Some(ref profile_name) = profile_name { + profile_name.as_str() + } else { + prog.to_str() + .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 + }) + }); + + if let Some(run_cfg) = run_config_opt { + let secrets_result = + run_cfg + .secrets + .as_ref() + .expect("no secrets configured for run profile") + .iter() + .map(|key| { + let secret_name = key.to_snake_case(); + debug!( + "Retrieving secret '{secret_name}' for run profile '{}'", + run_config_profile_name + ); + secrets_provider + .get_secret(&config, key.to_snake_case().as_str()) + .ok() + .map_or_else( + || { + debug!("Failed to fetch secret '{secret_name}' from secret provider"); + ( + key.to_uppercase(), + Err(anyhow!( + "Failed to fetch secret '{secret_name}' from secret provider" + )), + ) + }, + |value| if dry_run { + (key.to_uppercase(), Ok("*****".into())) + } else { + (key.to_uppercase(), Ok(value)) + }, + ) + }); + let err = secrets_result + .clone() + .filter(|(_, r)| r.is_err()) + .collect::>(); + if !err.is_empty() { + return Err(anyhow!( + "Failed to fetch {} secrets from secret provider. {}", + err.len(), + err.iter() + .map(|(k, _)| format!("\n'{}'", k)) + .collect::>() + .join(", ") + )); + } + + let secrets = secrets_result + .map(|(k, r)| (k, r.unwrap())) + .collect::>(); + let mut cmd_def = Command::new(prog); + + if run_cfg.flag.is_some() { + let args = parse_args(args, run_cfg, secrets.clone(), dry_run)?; + run_cmd(&mut cmd_def.args(&args), dry_run)?; + } else { + run_cmd(cmd_def.args(args).envs(secrets), dry_run)?; + } + } else { + debug!("No run profile found for '{run_config_profile_name}'"); + return Err(anyhow!( + "No run profile found for '{run_config_profile_name}'" + )); + } + + Ok(()) +} + +fn run_cmd(cmd: &mut Command, dry_run: bool) -> Result<()> { + if dry_run { + eprintln!("Command to be executed: {}", preview_command(cmd)); + } else { + cmd.status() + .with_context(|| format!("failed to execute command '{:?}'", cmd))?; + } + Ok(()) +} + +fn parse_args( + args: &[OsString], + run_config: &RunConfig, + secrets: HashMap, + dry_run: bool, +) -> Result> { + let args = args.to_vec(); + let flag = run_config + .flag + .as_ref() + .ok_or_else(|| anyhow!("flag must be set if arg_format is set"))?; + let flag_position = run_config + .flag_position + .ok_or_else(|| anyhow!("flag_position must be set if flag is set"))?; + let arg_format = run_config + .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 }; + args.push(OsString::from(flag)); + args.push(OsString::from( + arg_format + .replace(ARG_FORMAT_PLACEHOLDER_KEY, k) + .replace(ARG_FORMAT_PLACEHOLDER_VALUE, v), + )); + }) + } else { + secrets.iter().for_each(|(k, v)| { + let v = if dry_run { "*****" } else { v }; + args.insert( + flag_position, + OsString::from( + arg_format + .replace(ARG_FORMAT_PLACEHOLDER_KEY, k) + .replace(ARG_FORMAT_PLACEHOLDER_VALUE, v), + ), + ); + args.insert(flag_position, OsString::from(flag)); + }) + } + + Ok(args) +} + fn read_all_stdin() -> Result { if io::stdin().is_terminal() { #[cfg(not(windows))] diff --git a/src/config.rs b/src/config.rs index 35adbc6..1956a1c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,8 +3,52 @@ use log::debug; use serde::{Deserialize, Serialize}; use serde_with::DisplayFromStr; use serde_with::serde_as; +use std::borrow::Cow; use std::path::PathBuf; -use validator::Validate; +use validator::{Validate, ValidationError}; + +#[derive(Debug, Clone, Validate, Serialize, Deserialize)] +#[validate(schema(function = "flags_or_none", skip_on_field_errors = false))] +pub struct RunConfig { + #[validate(required)] + pub name: Option, + #[validate(required)] + pub secrets: Option>, + pub flag: Option, + #[validate(range(min = 1))] + pub flag_position: Option, + pub arg_format: Option, +} + +fn flags_or_none(run_config: &RunConfig) -> Result<(), ValidationError> { + match ( + &run_config.flag, + &run_config.flag_position, + &run_config.arg_format, + ) { + (Some(_), Some(_), Some(format)) => { + let has_key = format.contains("{key}"); + let has_value = format.contains("{value}"); + if has_key && has_value { + Ok(()) + } else { + let mut err = ValidationError::new("missing_placeholders"); + err.message = Some(Cow::Borrowed("must contain both '{key}' and '{value}' (with the '{' and '}' characters) in the arg_format")); + err.add_param(Cow::Borrowed("has_key"), &has_key); + err.add_param(Cow::Borrowed("has_value"), &has_value); + Err(err) + } + }, + (None, None, None) => Ok(()), + _ => { + let mut err = ValidationError::new("both_or_none"); + err.message = Some(Cow::Borrowed( + "When defining a flag to pass secrets into the command with, all of 'flag', 'flag_position', and 'arg_format' must be defined in the run configuration", + )); + Err(err) + } + } +} #[serde_as] #[derive(Debug, Clone, Validate, Serialize, Deserialize)] @@ -13,12 +57,14 @@ pub struct Config { pub provider: SupportedProvider, pub password_file: Option, pub git_branch: Option, - /// The git remote URL to push changes to (e.g. git@github.com:user/repo.git) + /// The git remote URL to push changes to (e.g. git@github.com:user/repo.git) pub git_remote_url: Option, pub git_user_name: Option, #[validate(email)] pub git_user_email: Option, - pub git_executable: Option, + pub git_executable: Option, + #[validate(nested)] + pub run_configs: Option>, } impl Default for Config { @@ -30,7 +76,8 @@ impl Default for Config { git_remote_url: None, git_user_name: None, git_user_email: None, - git_executable: None, + git_executable: None, + run_configs: None, } } }