diff --git a/Cargo.lock b/Cargo.lock index d161762..05ed759 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -651,6 +651,7 @@ dependencies = [ "indoc", "log", "log4rs", + "regex", "rpassword", "secrecy", "serde", diff --git a/Cargo.toml b/Cargo.toml index 759d4c9..5f0ae6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ serde_json = "1.0.143" dialoguer = "0.12.0" chrono = "0.4.42" indoc = "2.0.6" +regex = "1.11.2" [[bin]] bench = false diff --git a/README.md b/README.md index 4a87187..46bfc0f 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,34 @@ necessary secrets as environment variables or command-line flags. - **Dry Run Mode**: Preview the command and the secrets that will be injected without actually executing it using the `--dry-run` flag. +## Example Use Cases + +### Create/Get/Delete Secrets Securely As You Need From Any Configured Provider + +```shell +# Add a secret to the 'local' provider +echo "someApiKey" | gman add my_api_key + +# Retrieve a secret from the 'aws_secrets_manager' provider +gman get -p aws_secrets_manager db_password + +# Delete a secret from the 'local' provider +gman delete my_api_key +``` + +### Automatically Inject Secrets Into Any Command + +```shell +# Can inject secrets as environment variables into the 'aws' CLI command +gman aws sts get-caller-identity + +# Inject secrets into 'docker run' command via '-e' flags +gman docker run --rm --entrypoint env busybox | grep -i 'token' + +# Inject secrets into configuration files automatically for the 'managarr' application +gman managarr +``` + ## Installation ### Cargo @@ -73,10 +101,43 @@ git_executable: null # Defaults to 'git' in PATH run_configs: null # List of run configurations (profiles) ``` +## Providers +`gman` supports multiple providers for secret storage. The default provider is `local`, which stores secrets in an +encrypted file on your filesystem. The following table shows the available and planned providers: + +**Key:** + +| Symbol | Status | +|--------|-----------| +| ✅ | Supported | +| 🕒 | Planned | +| 🚫 | Won't Add | + + +| Provider Name | Status | Configuration Docs | Comments | +|--------------------------------------------------------------------------------------------------------------------------|--------|--------------------------|--------------------------------------------| +| `local` | ✅ | [Local](#provider-local) | | +| [`aws_secrets_manager`](https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html) | 🕒 | | | +| [`aws_ssm_parameter_store`](https://docs.aws.amazon.com/secretsmanager/latest/userguide/integrating_parameterstore.html) | 🕒 | | | +| [`hashicorp_vault`](https://www.hashicorp.com/en/products/vault) | 🕒 | | | +| [`azure_key_vault`](https://azure.microsoft.com/en-us/products/key-vault/) | 🕒 | | | +| [`gcp_secret_manager`](https://cloud.google.com/security/products/secret-manager?hl=en) | 🕒 | | | +| [`1password`](https://1password.com/) | 🕒 | | | +| [`bitwarden`](https://bitwarden.com/) | 🕒 | | | +| [`dashlane`](https://www.dashlane.com/) | 🕒 | | Waiting for CLI support for adding secrets | +| [`lastpass`](https://www.lastpass.com/) | 🕒 | | | + ### Provider: `local` -The default `local` provider stores an encrypted vault file on your filesystem. For use across multiple systems, it can -sync with a remote Git repository. +The default `local` provider stores an encrypted vault file on your filesystem. Any time you attempt to access the local +vault (e.g., adding, retrieving, or deleting secrets), `gman` will prompt you for the password you used to encrypt the +applicable secrets. + +Similar to [Ansible Vault](https://docs.ansible.com/ansible/latest/vault_guide/vault_managing_passwords.html#storing-passwords-in-files), `gman` lets you store the password in a file for convenience. This is done via the +`password_file` configuration option. If you choose to use a password file, ensure that it is secured with appropriate +file permissions (e.g., `chmod 600 ~/.gman_password`). The default file for the password file is `~/.gman_password`. + +For use across multiple systems, `gman` can sync with a remote Git repository. **Important Notes for Git Sync:** - You **must** create the remote repository on your Git provider (e.g., GitHub) *before* attempting to sync. @@ -91,15 +152,25 @@ git_user_name: "Your Name" git_user_email: "your.email@example.com" ``` -### Run Configurations +## Run Configurations -Run configurations (or "profiles") tell `gman` how to inject secrets into a command. When you run `gman `, it -looks for a profile with a `name` matching ``. If found, it injects the specified secrets. If no profile is -found, `gman` will error out and report that it could not find the run config with that name. +Run configurations (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are +supported: + +1. [**Environment Variables** (default)](#basic-run-config-environment-variables) +2. [**Command-Line Flags**](#advanced-run-config-command-line-flags) +3. [**Files**](#advanced-run-config-files) + +When you wrap a command with `gman` and don't specify a specific run configuration via `--profile`, `gman` will look for +a profile with a `name` matching ``. If found, it injects the specified secrets. If no profile is found, `gman` +will error out and report that it could not find the run config with that name. + +You can manually specify which run configuration to use with the `--profile` flag. Again, if no profile is found with +that name, `gman` will error out. #### Important: Secret names are always injected in `UPPER_SNAKE_CASE` format. -#### Basic Run Config (Environment Variables) +### Environment Variable Secret Injection By default, secrets are injected as environment variables. The two required fields are `name` and `secrets`. @@ -114,7 +185,7 @@ run_configs: When you run `gman aws ...`, `gman` will fetch these two secrets and expose them as environment variables to the `aws` process. -#### Advanced Run Config (Command-Line Flags) +### Inject Secrets via Command-Line Flags For applications that don't read environment variables, you can configure `gman` to pass secrets as command-line flags. This requires three additional fields: `flag`, `flag_position`, and `arg_format`. @@ -122,8 +193,8 @@ This requires three additional fields: `flag`, `flag_position`, and `arg_format` - `flag`: The flag to use (e.g., `-e`). - `flag_position`: An integer indicating where to insert the flag in the command's arguments. `1` is immediately after the command name. -- `arg_format`: A string that defines how the secret is formatted. It **must** contain the placeholders `{key}` and - `{value}`. +- `arg_format`: A string that defines how the secret is formatted. It **must** contain the placeholders `{{key}}` and + `{{value}}`. **Example:** A profile for `docker run` that uses the `-e` flag. ```yaml @@ -134,12 +205,58 @@ run_configs: - my_app_db_password flag: -e flag_position: 2 # In 'docker run ...', the flag comes after 'run', so position 2. - arg_format: "{key}={value}" + arg_format: "{{key}}={{value}}" ``` When you run `gman docker run my-image`, `gman` will execute a command similar to: `docker run -e MY_APP_API_KEY=... -e MY_APP_DB_PASSWORD=... my-image` -## Usage +### Inject Secrets into Files + +For applications that require secrets to be provided via files, you can configure `gman` to automatically populate +specified files with the secret values before executing the command, run the command, and then restore the original +content regardless of command completion status. + +This just requires one additional field: + +- `files`: A list of _absolute_ file paths where the secret values should be written. + +**Example:** An implicit profile for [`managarr`](https://github.com/Dark-Alex-17/managarr) that injects the specified +secrets into the corresponding configuration file. More than one file can be specified, and if `gman` can't find any +specified secrets, it will leave the file unchanged. + + +```yaml +run_configs: + - name: managarr + secrets: + - radarr_api_key + - sonarr_api_key # Remember that secret names are always converted to UPPER_SNAKE_CASE + files: + - /home/user/.config/managarr/config.yml +``` + +And this is what my `managarr` configuration file looks like: + +```yaml +radarr: + - name: Radarr + host: 192.168.0.105 + port: 7878 + api_token: {{RADARR_API_KEY}} # This will be replaced by gman with the actual secret value +sonarr: + - name: Sonarr + host: 192.168.0.105 + port: 8989 + api_token: {{sonarr_api_key}} # gman is case-insensitive, so this will also be replaced correctly +``` + +Then, all you need to do to run `managarr` with the secrets injected is: + +```shell +gman managarr +``` + +## Detailed Usage ### Storing and Managing Secrets diff --git a/src/bin/gman/command.rs b/src/bin/gman/command.rs index fc6f170..291de48 100644 --- a/src/bin/gman/command.rs +++ b/src/bin/gman/command.rs @@ -1,104 +1,104 @@ use std::borrow::Cow; -use std::ffi::{OsStr}; +use std::ffi::OsStr; use std::process::Command; pub fn preview_command(cmd: &Command) -> String { - #[cfg(unix)] - { - let mut parts: Vec = Vec::new(); + #[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))), - } - } + 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))), + } + } - parts.push(sh_escape(cmd.get_program()).into_owned()); - parts.extend(cmd.get_args().map(|a| sh_escape(a).into_owned())); - parts.join(" ") - } + 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(); + #[cfg(windows)] + { + let mut parts: Vec = Vec::new(); - 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))), - } - } - if !env_bits.is_empty() { - parts.push(env_bits.join(" && ")); - parts.push("&&".to_owned()); - } + 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))), + } + } + if !env_bits.is_empty() { + parts.push(env_bits.join(" && ")); + parts.push("&&".to_owned()); + } - parts.push(win_quote(cmd.get_program())); - parts.extend(cmd.get_args().map(win_quote)); - parts.join(" ") - } + 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()) - } + 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 { - 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 + 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 } #[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() - } + 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 f03a57c..c87d372 100644 --- a/src/bin/gman/main.rs +++ b/src/bin/gman/main.rs @@ -13,18 +13,20 @@ use gman::config::{Config, RunConfig}; use gman::providers::local::LocalProvider; use gman::providers::{SecretProvider, SupportedProvider}; use heck::ToSnakeCase; -use log::debug; +use log::{debug, error}; +use regex::Regex; use std::io::{self, IsTerminal, Read, Write}; -use std::panic; use std::panic::PanicHookInfo; +use std::path::PathBuf; use std::process::Command; +use std::{fs, panic}; use validator::Validate; mod command; mod utils; -const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{key}"; -const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{value}"; +const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}"; +const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}"; #[derive(Debug, Clone, ValueEnum)] enum OutputFormat { @@ -41,7 +43,7 @@ pub enum ProviderKind { impl From for SupportedProvider { fn from(k: ProviderKind) -> Self { match k { - ProviderKind::Local => SupportedProvider::Local(LocalProvider::default()), + ProviderKind::Local => SupportedProvider::Local(LocalProvider), } } } @@ -182,12 +184,11 @@ fn main() -> Result<()> { } Commands::Delete { name } => { let snake_case_name = name.to_snake_case().to_uppercase(); - secrets_provider - .delete_secret(&snake_case_name) - .map(|_| match cli.output { - None => println!("✓ Secret '{snake_case_name}' deleted from the vault."), - Some(_) => (), - })?; + secrets_provider.delete_secret(&snake_case_name).map(|_| { + if cli.output.is_none() { + println!("✓ Secret '{snake_case_name}' deleted from the vault.") + } + })?; } Commands::List {} => { let secrets = secrets_provider.list_secrets()?; @@ -216,12 +217,11 @@ fn main() -> Result<()> { } } Commands::Sync {} => { - secrets_provider - .sync(&mut config) - .map(|_| match cli.output { - None => println!("✓ Secrets synchronized with remote"), - Some(_) => (), - })?; + secrets_provider.sync(&mut 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)?; @@ -245,7 +245,7 @@ fn load_config(cli: &Cli) -> Result { if let Some(provider_kind) = &cli.provider { let provider: SupportedProvider = provider_kind.clone().into(); - config.provider = provider.into(); + config.provider = provider; } Ok(config) @@ -274,20 +274,19 @@ pub fn wrap_and_run_command( }); 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().to_uppercase(); - debug!( - "Retrieving secret '{secret_name}' for run profile '{}'", - run_config_profile_name - ); - secrets_provider - .get_secret(&config, key.to_snake_case().to_uppercase().as_str()) + 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().to_uppercase(); + debug!( + "Retrieving secret '{secret_name}' for run profile '{}'", + run_config_profile_name + ); + secrets_provider + .get_secret(config, key.to_snake_case().to_uppercase().as_str()) .ok() .map_or_else( || { @@ -299,13 +298,15 @@ pub fn wrap_and_run_command( )), ) }, - |value| if dry_run { - (key.to_uppercase(), Ok("*****".into())) - } else { - (key.to_uppercase(), Ok(value)) - }, + |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()) @@ -328,7 +329,69 @@ pub fn wrap_and_run_command( 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)?; + run_cmd(cmd_def.args(&args), dry_run)?; + } else if run_cfg.files.is_some() { + let injected_files = generate_files_secret_injections(secrets.clone(), run_cfg) + .with_context(|| "failed to inject secrets into files")?; + + for (file, original_content, new_content) in &injected_files { + if dry_run { + println!("Would inject secrets into file '{}'", file.display()); + } else { + match fs::write(file, new_content).with_context(|| { + format!( + "failed to write injected content to file '{}'", + file.display() + ) + }) { + Ok(_) => { + debug!("Injected secrets into file '{}'", file.display()); + } + Err(e) => { + error!( + "Failed to inject secrets into file '{}': {}", + file.display(), + e + ); + debug!("Restoring original content to file '{}'", file.display()); + fs::write(file, original_content) + .with_context(|| format!("failed to restore original content to file '{}' after injection failure: {}", file.display(), e))?; + return Err(e); + } + } + } + } + + match run_cmd(cmd_def.args(args), dry_run) { + Ok(_) => { + if !dry_run { + for (file, original_content, _) in &injected_files { + debug!("Restoring original content to file '{}'", file.display()); + fs::write(file, original_content).with_context(|| { + format!( + "failed to restore original content to file '{}'", + file.display() + ) + })?; + } + } + } + Err(e) => { + if !dry_run { + for (file, original_content, _) in &injected_files { + error!( + "Command execution failed, restoring original content to file '{}'", + file.display() + ); + debug!("Restoring original content to file '{}'", file.display()); + fs::write(file, original_content) + .with_context(|| format!("failed to restore original content to file '{}' after command execution failure: {}", file.display(), e))?; + } + } + + return Err(e); + } + } } else { run_cmd(cmd_def.args(args).envs(secrets), dry_run)?; } @@ -342,6 +405,41 @@ pub fn wrap_and_run_command( Ok(()) } +fn generate_files_secret_injections( + secrets: HashMap, + run_config: &RunConfig, +) -> Result> { + let re = Regex::new(r"\{\{([A-Za-z0-9_]+)\}\}")?; + let mut results = Vec::new(); + for file in run_config + .files + .as_ref() + .with_context(|| "no files configured for run profile")? + { + debug!( + "Generating file with injected secrets for '{}'", + file.display() + ); + let original_content = fs::read_to_string(file).with_context(|| { + format!( + "failed to read file for secrets injection: '{}'", + file.display() + ) + })?; + let new_content = re.replace_all(&original_content, |caps: ®ex::Captures| { + secrets + .get(&caps[1].to_snake_case().to_uppercase()) + .map(|s| s.as_str()) + .unwrap_or(&caps[0]) + .to_string() + }); + + results.push((file, original_content.to_string(), new_content.to_string())); + } + + Ok(results) +} + fn run_cmd(cmd: &mut Command, dry_run: bool) -> Result<()> { if dry_run { eprintln!("Command to be executed: {}", preview_command(cmd)); diff --git a/src/bin/gman/utils.rs b/src/bin/gman/utils.rs index bc15d19..bc7d7eb 100644 --- a/src/bin/gman/utils.rs +++ b/src/bin/gman/utils.rs @@ -1,43 +1,43 @@ -use std::fs; -use std::path::PathBuf; +use log::LevelFilter; use log4rs::append::file::FileAppender; use log4rs::config::{Appender, Root}; use log4rs::encode::pattern::PatternEncoder; -use log::LevelFilter; +use std::fs; +use std::path::PathBuf; pub fn init_logging_config() -> log4rs::Config { - let logfile = FileAppender::builder() - .encoder(Box::new(PatternEncoder::new( - "{d(%Y-%m-%d %H:%M:%S%.3f)(utc)} <{i}> [{l}] {f}:{L} - {m}{n}", - ))) - .build(get_log_path()) - .unwrap(); + let logfile = FileAppender::builder() + .encoder(Box::new(PatternEncoder::new( + "{d(%Y-%m-%d %H:%M:%S%.3f)(utc)} <{i}> [{l}] {f}:{L} - {m}{n}", + ))) + .build(get_log_path()) + .unwrap(); - log4rs::Config::builder() - .appender(Appender::builder().build("logfile", Box::new(logfile))) - .build( - Root::builder() - .appender("logfile") - .build(LevelFilter::Debug), - ) - .unwrap() + log4rs::Config::builder() + .appender(Appender::builder().build("logfile", Box::new(logfile))) + .build( + Root::builder() + .appender("logfile") + .build(LevelFilter::Debug), + ) + .unwrap() } pub fn get_log_path() -> PathBuf { - let mut log_path = if cfg!(target_os = "linux") { - dirs::cache_dir().unwrap_or_else(|| PathBuf::from("~/.cache")) - } else if cfg!(target_os = "macos") { - dirs::home_dir().unwrap().join("Library/Logs") - } else { - dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("C:\\Logs")) - }; + let mut log_path = if cfg!(target_os = "linux") { + dirs::cache_dir().unwrap_or_else(|| PathBuf::from("~/.cache")) + } else if cfg!(target_os = "macos") { + dirs::home_dir().unwrap().join("Library/Logs") + } else { + dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("C:\\Logs")) + }; - log_path.push("gman"); + log_path.push("gman"); - if let Err(e) = fs::create_dir_all(&log_path) { - eprintln!("Failed to create log directory: {e:?}"); - } + if let Err(e) = fs::create_dir_all(&log_path) { + eprintln!("Failed to create log directory: {e:?}"); + } - log_path.push("gman.log"); - log_path -} \ No newline at end of file + log_path.push("gman.log"); + log_path +} diff --git a/src/config.rs b/src/config.rs index 7b5e80b..23e2282 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,11 +9,13 @@ use validator::{Validate, ValidationError}; #[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"))] pub struct RunConfig { #[validate(required)] pub name: Option, #[validate(required)] pub secrets: Option>, + pub files: Option>, pub flag: Option, #[validate(range(min = 1))] pub flag_position: Option, @@ -27,19 +29,21 @@ fn flags_or_none(run_config: &RunConfig) -> Result<(), ValidationError> { &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 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( @@ -50,6 +54,19 @@ fn flags_or_none(run_config: &RunConfig) -> Result<(), ValidationError> { } } +fn flags_or_files(run_config: &RunConfig) -> Result<(), ValidationError> { + match (&run_config.flag, &run_config.files) { + (Some(_), Some(_)) => { + let mut err = ValidationError::new("flag_and_file"); + err.message = Some(Cow::Borrowed( + "Cannot specify both 'flag' and 'file' in the same run configuration", + )); + Err(err) + } + _ => Ok(()), + } +} + #[serde_as] #[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] pub struct Config { @@ -93,10 +110,10 @@ impl Config { pub fn local_provider_password_file() -> Option { let mut path = dirs::home_dir().map(|p| p.join(".gman_password")); - if let Some(p) = &path { - if !p.exists() { - path = None; - } + if let Some(p) = &path + && !p.exists() + { + path = None; } path diff --git a/src/lib.rs b/src/lib.rs index b5d0ce1..8f8705b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,29 +1,29 @@ -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use argon2::{ - password_hash::{rand_core::RngCore, SaltString}, - Algorithm, Argon2, Params, Version, + Algorithm, Argon2, Params, Version, + password_hash::{SaltString, rand_core::RngCore}, }; -use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use chacha20poly1305::{ - aead::{Aead, KeyInit, OsRng}, - Key, XChaCha20Poly1305, XNonce, + Key, XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit, OsRng}, }; use secrecy::{ExposeSecret, SecretString}; use zeroize::Zeroize; -pub mod providers; pub mod config; +pub mod providers; -pub (in crate) const HEADER: &str = "$VAULT"; -pub (in crate) const VERSION: &str = "v1"; -pub (in crate) const KDF: &str = "argon2id"; +pub(crate) const HEADER: &str = "$VAULT"; +pub(crate) const VERSION: &str = "v1"; +pub(crate) const KDF: &str = "argon2id"; -pub (in crate) const ARGON_M_COST_KIB: u32 = 19_456; -pub (in crate) const ARGON_T_COST: u32 = 2; -pub (in crate) const ARGON_P: u32 = 1; +pub(crate) const ARGON_M_COST_KIB: u32 = 19_456; +pub(crate) const ARGON_T_COST: u32 = 2; +pub(crate) const ARGON_P: u32 = 1; -pub (in crate) const SALT_LEN: usize = 16; -pub (in crate) const NONCE_LEN: usize = 24; -pub (in crate) const KEY_LEN: usize = 32; +pub(crate) const SALT_LEN: usize = 16; +pub(crate) const NONCE_LEN: usize = 24; +pub(crate) const KEY_LEN: usize = 32; fn derive_key(password: &SecretString, salt: &[u8]) -> Result { let params = Params::new(ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P, Some(KEY_LEN)) @@ -32,14 +32,10 @@ fn derive_key(password: &SecretString, salt: &[u8]) -> Result { let mut key_bytes = [0u8; KEY_LEN]; argon - .hash_password_into( - password.expose_secret().as_bytes(), - salt, - &mut key_bytes, - ) + .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.clone(); + let cloned_key_bytes = key_bytes; let key = Key::from_slice(&cloned_key_bytes); key_bytes.zeroize(); Ok(*key) @@ -211,7 +207,9 @@ mod tests { let env = encrypt_string(pw.clone(), msg).unwrap(); 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(); + let mut ct = base64::engine::general_purpose::STANDARD + .decode(ct_b64) + .unwrap(); ct[0] ^= 0x01; // Flip a bit let new_ct_b64 = base64::engine::general_purpose::STANDARD.encode(&ct); let new_ct_part = format!("ct={}", new_ct_b64); diff --git a/src/providers/git_sync.rs b/src/providers/git_sync.rs index eca2e43..d306882 100644 --- a/src/providers/git_sync.rs +++ b/src/providers/git_sync.rs @@ -89,7 +89,7 @@ fn resolve_git_username(git: &Path, name: Option<&String>) -> Result { return Ok(name.to_string()); } - run_git_config_capture(&git, &["config", "user.name"]) + run_git_config_capture(git, &["config", "user.name"]) .with_context(|| "unable to determine git username") } @@ -99,7 +99,7 @@ fn resolve_git_email(git: &Path, email: Option<&String>) -> Result { return Ok(email.to_string()); } - run_git_config_capture(&git, &["config", "user.email"]) + run_git_config_capture(git, &["config", "user.email"]) .with_context(|| "unable to determine git user email") } @@ -210,17 +210,15 @@ fn set_origin(git: &Path, repo: &Path, url: &str) -> Result<()> { if has_origin { run_git(git, repo, &["remote", "set-url", "origin", url])?; - } else { - if Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt(format!("Have you already created the remote origin '{url}' on the Git host so we can push to it?")) - .default(false) - .interact()? - { - run_git(git, repo, &["remote", "add", "origin", url])?; - } else { - return Err(anyhow!("Remote origin does not yet exist. Please create remote origin before synchronizing, then try again")); - } - } + } else if Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(format!("Have you already created the remote origin '{url}' on the Git host so we can push to it?")) + .default(false) + .interact()? + { + run_git(git, repo, &["remote", "add", "origin", url])?; + } else { + return Err(anyhow!("Remote origin does not yet exist. Please create remote origin before synchronizing, then try again")); + } Ok(()) } diff --git a/src/providers/local.rs b/src/providers/local.rs index b7404d3..93d7161 100644 --- a/src/providers/local.rs +++ b/src/providers/local.rs @@ -54,7 +54,7 @@ impl SecretProvider for LocalProvider { .get(key) .with_context(|| format!("key '{key}' not found in the vault"))?; - let password = get_password(&config)?; + let password = get_password(config)?; let plaintext = decrypt_string(&password, envelope)?; drop(password); @@ -70,7 +70,7 @@ impl SecretProvider for LocalProvider { bail!("key '{key}' already exists"); } - let password = get_password(&config)?; + let password = get_password(config)?; let envelope = encrypt_string(&password, value)?; drop(password); @@ -82,7 +82,7 @@ impl SecretProvider for LocalProvider { fn update_secret(&self, config: &Config, key: &str, value: &str) -> Result<()> { let mut vault: HashMap = confy::load("gman", "vault").unwrap_or_default(); - let password = get_password(&config)?; + let password = get_password(config)?; let envelope = encrypt_string(&password, value)?; drop(password); @@ -317,7 +317,7 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result { fn get_password(config: &Config) -> Result { if let Some(password_file) = &config.password_file { let password = SecretString::new( - fs::read_to_string(&password_file) + fs::read_to_string(password_file) .with_context(|| format!("failed to read password file {:?}", password_file))? .trim() .to_string() diff --git a/tests/config_tests.rs b/tests/config_tests.rs index 0121625..e5c05d3 100644 --- a/tests/config_tests.rs +++ b/tests/config_tests.rs @@ -1,166 +1,210 @@ #[cfg(test)] mod tests { - use gman::config::{Config, RunConfig}; - use gman::providers::local::LocalProvider; - use gman::providers::SupportedProvider; + use gman::config::{Config, RunConfig}; + use gman::providers::SupportedProvider; + use gman::providers::local::LocalProvider; - use validator::Validate; + use validator::Validate; - #[test] - fn test_run_config_valid() { - let run_config = RunConfig { - name: Some("test".to_string()), - secrets: Some(vec!["secret1".to_string()]), - flag: None, - flag_position: None, - arg_format: None, - }; - assert!(run_config.validate().is_ok()); - } + #[test] + fn test_run_config_valid() { + let run_config = RunConfig { + name: Some("test".to_string()), + secrets: Some(vec!["secret1".to_string()]), + flag: None, + flag_position: None, + arg_format: None, + files: None, + }; + assert!(run_config.validate().is_ok()); + } - #[test] - fn test_run_config_missing_name() { - let run_config = RunConfig { - name: None, - secrets: Some(vec!["secret1".to_string()]), - flag: None, - flag_position: None, - arg_format: None, - }; - assert!(run_config.validate().is_err()); - } + #[test] + fn test_run_config_missing_name() { + let run_config = RunConfig { + name: None, + secrets: Some(vec!["secret1".to_string()]), + flag: None, + flag_position: None, + arg_format: None, + files: None, + }; + assert!(run_config.validate().is_err()); + } - #[test] - fn test_run_config_missing_secrets() { - let run_config = RunConfig { - name: Some("test".to_string()), - secrets: None, - flag: None, - flag_position: None, - arg_format: None, - }; - assert!(run_config.validate().is_err()); - } + #[test] + fn test_run_config_missing_secrets() { + let run_config = RunConfig { + name: Some("test".to_string()), + secrets: None, + flag: None, + flag_position: None, + arg_format: None, + files: None, + }; + assert!(run_config.validate().is_err()); + } - #[test] - fn test_run_config_invalid_flag_position() { - let run_config = RunConfig { - name: Some("test".to_string()), - secrets: Some(vec!["secret1".to_string()]), - flag: Some("--test-flag".to_string()), - flag_position: Some(0), - arg_format: Some("{key}={value}".to_string()), - }; - assert!(run_config.validate().is_err()); - } + #[test] + fn test_run_config_invalid_flag_position() { + let run_config = RunConfig { + name: Some("test".to_string()), + secrets: Some(vec!["secret1".to_string()]), + flag: Some("--test-flag".to_string()), + flag_position: Some(0), + arg_format: Some("{{key}}={{value}}".to_string()), + files: None, + }; + assert!(run_config.validate().is_err()); + } - #[test] - fn test_run_config_flags_or_none_all_some() { - let run_config = RunConfig { - name: Some("test".to_string()), - secrets: Some(vec!["secret1".to_string()]), - flag: Some("--test-flag".to_string()), - flag_position: Some(1), - arg_format: Some("{key}={value}".to_string()), - }; - assert!(run_config.validate().is_ok()); - } + #[test] + fn test_run_config_flags_or_none_all_some() { + let run_config = RunConfig { + name: Some("test".to_string()), + secrets: Some(vec!["secret1".to_string()]), + flag: Some("--test-flag".to_string()), + flag_position: Some(1), + arg_format: Some("{{key}}={{value}}".to_string()), + files: None, + }; + assert!(run_config.validate().is_ok()); + } - #[test] - fn test_run_config_flags_or_none_all_none() { - let run_config = RunConfig { - name: Some("test".to_string()), - secrets: Some(vec!["secret1".to_string()]), - flag: None, - flag_position: None, - arg_format: None, - }; - assert!(run_config.validate().is_ok()); - } + #[test] + fn test_run_config_flags_or_none_all_none() { + let run_config = RunConfig { + name: Some("test".to_string()), + secrets: Some(vec!["secret1".to_string()]), + flag: None, + flag_position: None, + arg_format: None, + files: None, + }; + assert!(run_config.validate().is_ok()); + } - #[test] - fn test_run_config_flags_or_none_partial_some() { - let run_config = RunConfig { - name: Some("test".to_string()), - secrets: Some(vec!["secret1".to_string()]), - flag: Some("--test-flag".to_string()), - flag_position: None, - arg_format: None, - }; - assert!(run_config.validate().is_err()); - } + #[test] + fn test_run_config_flags_or_none_partial_some() { + let run_config = RunConfig { + name: Some("test".to_string()), + secrets: Some(vec!["secret1".to_string()]), + flag: Some("--test-flag".to_string()), + flag_position: None, + arg_format: None, + files: None, + }; + assert!(run_config.validate().is_err()); + } - #[test] - fn test_run_config_flags_or_none_missing_placeholder() { - let run_config = RunConfig { - name: Some("test".to_string()), - secrets: Some(vec!["secret1".to_string()]), - flag: Some("--test-flag".to_string()), - flag_position: Some(1), - arg_format: Some("key=value".to_string()), - }; - assert!(run_config.validate().is_err()); - } + #[test] + fn test_run_config_flags_or_none_missing_placeholder() { + let run_config = RunConfig { + name: Some("test".to_string()), + secrets: Some(vec!["secret1".to_string()]), + flag: Some("--test-flag".to_string()), + flag_position: Some(1), + arg_format: Some("key=value".to_string()), + files: None, + }; + assert!(run_config.validate().is_err()); + } - #[test] - fn test_config_valid() { - let config = Config { - provider: SupportedProvider::Local(LocalProvider), - password_file: None, - git_branch: None, - git_remote_url: None, - git_user_name: None, - git_user_email: Some("test@example.com".to_string()), - git_executable: None, - run_configs: None, - }; - assert!(config.validate().is_ok()); - } + #[test] + fn test_run_config_flags_or_files_all_none() { + let run_config = RunConfig { + name: Some("test".to_string()), + secrets: Some(vec!["secret1".to_string()]), + flag: None, + flag_position: None, + arg_format: None, + files: None, + }; + assert!(run_config.validate().is_ok()); + } - #[test] - fn test_config_invalid_email() { - let config = Config { - provider: SupportedProvider::Local(LocalProvider), - password_file: None, - git_branch: None, - git_remote_url: None, - git_user_name: None, - git_user_email: Some("test".to_string()), - git_executable: None, - run_configs: None, - }; - assert!(config.validate().is_err()); - } + #[test] + fn test_run_config_flags_or_files_files_is_some() { + let run_config = RunConfig { + name: Some("test".to_string()), + secrets: Some(vec!["secret1".to_string()]), + flag: None, + flag_position: None, + arg_format: None, + files: Some(Vec::new()), + }; + assert!(run_config.validate().is_ok()); + } - #[test] - fn test_config_default() { - let config = Config::default(); - assert_eq!( - config.provider, - SupportedProvider::Local(LocalProvider) - ); - assert_eq!(config.git_branch, Some("main".to_string())); - } + #[test] + fn test_run_config_flags_or_files_all_some() { + let run_config = RunConfig { + name: Some("test".to_string()), + secrets: Some(vec!["secret1".to_string()]), + flag: Some("--test-flag".to_string()), + flag_position: Some(1), + arg_format: Some("{{key}}={{value}}".to_string()), + files: Some(Vec::new()), + }; + assert!(run_config.validate().is_err()); + } - #[test] - fn test_config_extract_provider() { - let config = Config::default(); - let provider = config.extract_provider(); - assert_eq!(provider.name(), "LocalProvider"); - } + #[test] + fn test_config_valid() { + let config = Config { + provider: SupportedProvider::Local(LocalProvider), + password_file: None, + git_branch: None, + git_remote_url: None, + git_user_name: None, + git_user_email: Some("test@example.com".to_string()), + git_executable: None, + run_configs: None, + }; + assert!(config.validate().is_ok()); + } - #[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); - } - } -} \ No newline at end of file + #[test] + fn test_config_invalid_email() { + let config = Config { + provider: SupportedProvider::Local(LocalProvider), + password_file: None, + git_branch: None, + git_remote_url: None, + git_user_name: None, + git_user_email: Some("test".to_string()), + git_executable: None, + run_configs: None, + }; + assert!(config.validate().is_err()); + } + + #[test] + fn test_config_default() { + let config = Config::default(); + assert_eq!(config.provider, SupportedProvider::Local(LocalProvider)); + assert_eq!(config.git_branch, Some("main".to_string())); + } + + #[test] + fn test_config_extract_provider() { + let config = Config::default(); + let provider = config.extract_provider(); + assert_eq!(provider.name(), "LocalProvider"); + } + + #[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); + } + } +} diff --git a/tests/providers/local_tests.rs b/tests/providers/local_tests.rs index 9a89e2e..95b8d2d 100644 --- a/tests/providers/local_tests.rs +++ b/tests/providers/local_tests.rs @@ -1,6 +1,5 @@ use gman::providers::local::LocalProviderConfig; - #[test] fn test_local_provider_config_default() { let config = LocalProviderConfig::default(); diff --git a/tests/providers/mod_tests.rs b/tests/providers/mod_tests.rs index 18bb52e..d563287 100644 --- a/tests/providers/mod_tests.rs +++ b/tests/providers/mod_tests.rs @@ -1,5 +1,5 @@ -use gman::providers::{ParseProviderError, SupportedProvider}; use gman::providers::local::LocalProvider; +use gman::providers::{ParseProviderError, SupportedProvider}; use std::str::FromStr; #[test] @@ -17,10 +17,7 @@ fn test_supported_provider_from_str_valid() { #[test] fn test_supported_provider_from_str_invalid() { let err = SupportedProvider::from_str("invalid").unwrap_err(); - assert_eq!( - err.to_string(), - "unsupported provider 'invalid'" - ); + assert_eq!(err.to_string(), "unsupported provider 'invalid'"); } #[test]