Full support for secret injection into configuration files

This commit is contained in:
2025-09-10 20:53:10 -06:00
parent 8ae9b19567
commit 17eba4413d
13 changed files with 647 additions and 377 deletions
Generated
+1
View File
@@ -651,6 +651,7 @@ dependencies = [
"indoc", "indoc",
"log", "log",
"log4rs", "log4rs",
"regex",
"rpassword", "rpassword",
"secrecy", "secrecy",
"serde", "serde",
+1
View File
@@ -39,6 +39,7 @@ serde_json = "1.0.143"
dialoguer = "0.12.0" dialoguer = "0.12.0"
chrono = "0.4.42" chrono = "0.4.42"
indoc = "2.0.6" indoc = "2.0.6"
regex = "1.11.2"
[[bin]] [[bin]]
bench = false bench = false
+129 -12
View File
@@ -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 Mode**: Preview the command and the secrets that will be injected without actually executing it using the
`--dry-run` flag. `--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 ## Installation
### Cargo ### Cargo
@@ -73,10 +101,43 @@ git_executable: null # Defaults to 'git' in PATH
run_configs: null # List of run configurations (profiles) 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` ### Provider: `local`
The default `local` provider stores an encrypted vault file on your filesystem. For use across multiple systems, it can The default `local` provider stores an encrypted vault file on your filesystem. Any time you attempt to access the local
sync with a remote Git repository. 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:** **Important Notes for Git Sync:**
- You **must** create the remote repository on your Git provider (e.g., GitHub) *before* attempting to 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" 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 <command>`, it Run configurations (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are
looks for a profile with a `name` matching `<command>`. If found, it injects the specified secrets. If no profile is supported:
found, `gman` will error out and report that it could not find the run config with that name.
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 `<command>`. 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. #### 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`. 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` When you run `gman aws ...`, `gman` will fetch these two secrets and expose them as environment variables to the `aws`
process. 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. 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`. 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`: 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 - `flag_position`: An integer indicating where to insert the flag in the command's arguments. `1` is immediately after
the command name. the command name.
- `arg_format`: A string that defines how the secret is formatted. It **must** contain the placeholders `{key}` and - `arg_format`: A string that defines how the secret is formatted. It **must** contain the placeholders `{{key}}` and
`{value}`. `{{value}}`.
**Example:** A profile for `docker run` that uses the `-e` flag. **Example:** A profile for `docker run` that uses the `-e` flag.
```yaml ```yaml
@@ -134,12 +205,58 @@ run_configs:
- my_app_db_password - my_app_db_password
flag: -e flag: -e
flag_position: 2 # In 'docker run ...', the flag comes after 'run', so position 2. 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: 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` `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 ### Storing and Managing Secrets
+82 -82
View File
@@ -1,104 +1,104 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::ffi::{OsStr}; use std::ffi::OsStr;
use std::process::Command; use std::process::Command;
pub fn preview_command(cmd: &Command) -> String { pub fn preview_command(cmd: &Command) -> String {
#[cfg(unix)] #[cfg(unix)]
{ {
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
for (k, vopt) in cmd.get_envs() { for (k, vopt) in cmd.get_envs() {
match vopt { match vopt {
Some(v) => parts.push(format!( Some(v) => parts.push(format!("{}={}", sh_escape(k), sh_escape(v),)),
"{}={}", None => parts.push(format!("unset {}", sh_escape(k))),
sh_escape(k), }
sh_escape(v), }
)),
None => parts.push(format!("unset {}", sh_escape(k))),
}
}
parts.push(sh_escape(cmd.get_program()).into_owned()); parts.push(sh_escape(cmd.get_program()).into_owned());
parts.extend(cmd.get_args().map(|a| sh_escape(a).into_owned())); parts.extend(cmd.get_args().map(|a| sh_escape(a).into_owned()));
parts.join(" ") parts.join(" ")
} }
#[cfg(windows)] #[cfg(windows)]
{ {
let mut parts: Vec<String> = Vec::new(); let mut parts: Vec<String> = Vec::new();
let mut env_bits = Vec::new(); let mut env_bits = Vec::new();
for (k, vopt) in cmd.get_envs() { for (k, vopt) in cmd.get_envs() {
match vopt { match vopt {
Some(v) => env_bits.push(format!("set {}={}", ps_quote(k), ps_quote(v))), Some(v) => env_bits.push(format!("set {}={}", ps_quote(k), ps_quote(v))),
None => env_bits.push(format!("set {}=", ps_quote(k))), None => env_bits.push(format!("set {}=", ps_quote(k))),
} }
} }
if !env_bits.is_empty() { if !env_bits.is_empty() {
parts.push(env_bits.join(" && ")); parts.push(env_bits.join(" && "));
parts.push("&&".to_owned()); parts.push("&&".to_owned());
} }
parts.push(win_quote(cmd.get_program())); parts.push(win_quote(cmd.get_program()));
parts.extend(cmd.get_args().map(win_quote)); parts.extend(cmd.get_args().map(win_quote));
parts.join(" ") parts.join(" ")
} }
} }
#[cfg(unix)] #[cfg(unix)]
fn sh_escape(s: &OsStr) -> Cow<'_, str> { fn sh_escape(s: &OsStr) -> Cow<'_, str> {
let s = s.to_string_lossy(); let s = s.to_string_lossy();
if s.is_empty() || s.chars().any(|c| c.is_whitespace() || "!\"#$&'()*;<>?`\\|[]{}".contains(c)) if s.is_empty()
{ || s.chars()
let mut out = String::from("'"); .any(|c| c.is_whitespace() || "!\"#$&'()*;<>?`\\|[]{}".contains(c))
for ch in s.chars() { {
if ch == '\'' { let mut out = String::from("'");
out.push_str("'\\''"); for ch in s.chars() {
} else { if ch == '\'' {
out.push(ch); out.push_str("'\\''");
} } else {
} out.push(ch);
out.push('\''); }
Cow::Owned(out) }
} else { out.push('\'');
Cow::Owned(s.into_owned()) Cow::Owned(out)
} } else {
Cow::Owned(s.into_owned())
}
} }
#[cfg(windows)] #[cfg(windows)]
fn win_quote(s: &OsStr) -> String { fn win_quote(s: &OsStr) -> String {
let s = s.to_string_lossy(); let s = s.to_string_lossy();
if !s.contains([' ', '\t', '"', '\\']) { if !s.contains([' ', '\t', '"', '\\']) {
return s.into_owned(); return s.into_owned();
} }
let mut out = String::from("\""); let mut out = String::from("\"");
let mut backslashes = 0; let mut backslashes = 0;
for ch in s.chars() { for ch in s.chars() {
match ch { match ch {
'\\' => backslashes += 1, '\\' => backslashes += 1,
'"' => { '"' => {
out.extend(std::iter::repeat('\\').take(backslashes * 2 + 1)); out.extend(std::iter::repeat('\\').take(backslashes * 2 + 1));
out.push('"'); out.push('"');
backslashes = 0; backslashes = 0;
} }
_ => { _ => {
out.extend(std::iter::repeat('\\').take(backslashes)); out.extend(std::iter::repeat('\\').take(backslashes));
backslashes = 0; backslashes = 0;
out.push(ch); out.push(ch);
} }
} }
} }
out.extend(std::iter::repeat('\\').take(backslashes)); out.extend(std::iter::repeat('\\').take(backslashes));
out.push('"'); out.push('"');
out out
} }
#[cfg(windows)] #[cfg(windows)]
fn ps_quote(s: &OsStr) -> String { fn ps_quote(s: &OsStr) -> String {
let s = s.to_string_lossy(); let s = s.to_string_lossy();
if s.chars().any(|c| c.is_whitespace() || r#"'&|<>()^"%!;"#.contains(c)) { if s.chars()
format!("'{}'", s.replace('\'', "''")) .any(|c| c.is_whitespace() || r#"'&|<>()^"%!;"#.contains(c))
} else { {
s.into_owned() format!("'{}'", s.replace('\'', "''"))
} } else {
s.into_owned()
}
} }
+137 -39
View File
@@ -13,18 +13,20 @@ use gman::config::{Config, RunConfig};
use gman::providers::local::LocalProvider; use gman::providers::local::LocalProvider;
use gman::providers::{SecretProvider, SupportedProvider}; use gman::providers::{SecretProvider, SupportedProvider};
use heck::ToSnakeCase; use heck::ToSnakeCase;
use log::debug; use log::{debug, error};
use regex::Regex;
use std::io::{self, IsTerminal, Read, Write}; use std::io::{self, IsTerminal, Read, Write};
use std::panic;
use std::panic::PanicHookInfo; use std::panic::PanicHookInfo;
use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use std::{fs, panic};
use validator::Validate; use validator::Validate;
mod command; mod command;
mod utils; mod utils;
const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{key}"; const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}";
const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{value}"; const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}";
#[derive(Debug, Clone, ValueEnum)] #[derive(Debug, Clone, ValueEnum)]
enum OutputFormat { enum OutputFormat {
@@ -41,7 +43,7 @@ pub enum ProviderKind {
impl From<ProviderKind> for SupportedProvider { impl From<ProviderKind> for SupportedProvider {
fn from(k: ProviderKind) -> Self { fn from(k: ProviderKind) -> Self {
match k { match k {
ProviderKind::Local => SupportedProvider::Local(LocalProvider::default()), ProviderKind::Local => SupportedProvider::Local(LocalProvider),
} }
} }
} }
@@ -182,12 +184,11 @@ fn main() -> Result<()> {
} }
Commands::Delete { name } => { Commands::Delete { name } => {
let snake_case_name = name.to_snake_case().to_uppercase(); let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider secrets_provider.delete_secret(&snake_case_name).map(|_| {
.delete_secret(&snake_case_name) if cli.output.is_none() {
.map(|_| match cli.output { println!("✓ Secret '{snake_case_name}' deleted from the vault.")
None => println!("✓ Secret '{snake_case_name}' deleted from the vault."), }
Some(_) => (), })?;
})?;
} }
Commands::List {} => { Commands::List {} => {
let secrets = secrets_provider.list_secrets()?; let secrets = secrets_provider.list_secrets()?;
@@ -216,12 +217,11 @@ fn main() -> Result<()> {
} }
} }
Commands::Sync {} => { Commands::Sync {} => {
secrets_provider secrets_provider.sync(&mut config).map(|_| {
.sync(&mut config) if cli.output.is_none() {
.map(|_| match cli.output { println!("✓ Secrets synchronized with remote")
None => println!("✓ Secrets synchronized with remote"), }
Some(_) => (), })?;
})?;
} }
Commands::External(tokens) => { Commands::External(tokens) => {
wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run)?; wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run)?;
@@ -245,7 +245,7 @@ fn load_config(cli: &Cli) -> Result<Config> {
if let Some(provider_kind) = &cli.provider { if let Some(provider_kind) = &cli.provider {
let provider: SupportedProvider = provider_kind.clone().into(); let provider: SupportedProvider = provider_kind.clone().into();
config.provider = provider.into(); config.provider = provider;
} }
Ok(config) Ok(config)
@@ -274,20 +274,19 @@ pub fn wrap_and_run_command(
}); });
if let Some(run_cfg) = run_config_opt { if let Some(run_cfg) = run_config_opt {
let secrets_result = let secrets_result = run_cfg
run_cfg .secrets
.secrets .as_ref()
.as_ref() .expect("no secrets configured for run profile")
.expect("no secrets configured for run profile") .iter()
.iter() .map(|key| {
.map(|key| { let secret_name = key.to_snake_case().to_uppercase();
let secret_name = key.to_snake_case().to_uppercase(); debug!(
debug!( "Retrieving secret '{secret_name}' for run profile '{}'",
"Retrieving secret '{secret_name}' for run profile '{}'", run_config_profile_name
run_config_profile_name );
); secrets_provider
secrets_provider .get_secret(config, key.to_snake_case().to_uppercase().as_str())
.get_secret(&config, key.to_snake_case().to_uppercase().as_str())
.ok() .ok()
.map_or_else( .map_or_else(
|| { || {
@@ -299,13 +298,15 @@ pub fn wrap_and_run_command(
)), )),
) )
}, },
|value| if dry_run { |value| {
(key.to_uppercase(), Ok("*****".into())) if dry_run {
} else { (key.to_uppercase(), Ok("*****".into()))
(key.to_uppercase(), Ok(value)) } else {
}, (key.to_uppercase(), Ok(value))
}
},
) )
}); });
let err = secrets_result let err = secrets_result
.clone() .clone()
.filter(|(_, r)| r.is_err()) .filter(|(_, r)| r.is_err())
@@ -328,7 +329,69 @@ pub fn wrap_and_run_command(
if run_cfg.flag.is_some() { if run_cfg.flag.is_some() {
let args = parse_args(args, run_cfg, secrets.clone(), dry_run)?; 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 { } else {
run_cmd(cmd_def.args(args).envs(secrets), dry_run)?; run_cmd(cmd_def.args(args).envs(secrets), dry_run)?;
} }
@@ -342,6 +405,41 @@ pub fn wrap_and_run_command(
Ok(()) Ok(())
} }
fn generate_files_secret_injections(
secrets: HashMap<String, String>,
run_config: &RunConfig,
) -> Result<Vec<(&PathBuf, String, String)>> {
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: &regex::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<()> { fn run_cmd(cmd: &mut Command, dry_run: bool) -> Result<()> {
if dry_run { if dry_run {
eprintln!("Command to be executed: {}", preview_command(cmd)); eprintln!("Command to be executed: {}", preview_command(cmd));
+30 -30
View File
@@ -1,43 +1,43 @@
use std::fs; use log::LevelFilter;
use std::path::PathBuf;
use log4rs::append::file::FileAppender; use log4rs::append::file::FileAppender;
use log4rs::config::{Appender, Root}; use log4rs::config::{Appender, Root};
use log4rs::encode::pattern::PatternEncoder; use log4rs::encode::pattern::PatternEncoder;
use log::LevelFilter; use std::fs;
use std::path::PathBuf;
pub fn init_logging_config() -> log4rs::Config { pub fn init_logging_config() -> log4rs::Config {
let logfile = FileAppender::builder() let logfile = FileAppender::builder()
.encoder(Box::new(PatternEncoder::new( .encoder(Box::new(PatternEncoder::new(
"{d(%Y-%m-%d %H:%M:%S%.3f)(utc)} <{i}> [{l}] {f}:{L} - {m}{n}", "{d(%Y-%m-%d %H:%M:%S%.3f)(utc)} <{i}> [{l}] {f}:{L} - {m}{n}",
))) )))
.build(get_log_path()) .build(get_log_path())
.unwrap(); .unwrap();
log4rs::Config::builder() log4rs::Config::builder()
.appender(Appender::builder().build("logfile", Box::new(logfile))) .appender(Appender::builder().build("logfile", Box::new(logfile)))
.build( .build(
Root::builder() Root::builder()
.appender("logfile") .appender("logfile")
.build(LevelFilter::Debug), .build(LevelFilter::Debug),
) )
.unwrap() .unwrap()
} }
pub fn get_log_path() -> PathBuf { pub fn get_log_path() -> PathBuf {
let mut log_path = if cfg!(target_os = "linux") { let mut log_path = if cfg!(target_os = "linux") {
dirs::cache_dir().unwrap_or_else(|| PathBuf::from("~/.cache")) dirs::cache_dir().unwrap_or_else(|| PathBuf::from("~/.cache"))
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
dirs::home_dir().unwrap().join("Library/Logs") dirs::home_dir().unwrap().join("Library/Logs")
} else { } else {
dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("C:\\Logs")) 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) { if let Err(e) = fs::create_dir_all(&log_path) {
eprintln!("Failed to create log directory: {e:?}"); eprintln!("Failed to create log directory: {e:?}");
} }
log_path.push("gman.log"); log_path.push("gman.log");
log_path log_path
} }
+34 -17
View File
@@ -9,11 +9,13 @@ use validator::{Validate, ValidationError};
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
#[validate(schema(function = "flags_or_none", skip_on_field_errors = false))] #[validate(schema(function = "flags_or_none", skip_on_field_errors = false))]
#[validate(schema(function = "flags_or_files"))]
pub struct RunConfig { pub struct RunConfig {
#[validate(required)] #[validate(required)]
pub name: Option<String>, pub name: Option<String>,
#[validate(required)] #[validate(required)]
pub secrets: Option<Vec<String>>, pub secrets: Option<Vec<String>>,
pub files: Option<Vec<PathBuf>>,
pub flag: Option<String>, pub flag: Option<String>,
#[validate(range(min = 1))] #[validate(range(min = 1))]
pub flag_position: Option<usize>, pub flag_position: Option<usize>,
@@ -27,19 +29,21 @@ fn flags_or_none(run_config: &RunConfig) -> Result<(), ValidationError> {
&run_config.arg_format, &run_config.arg_format,
) { ) {
(Some(_), Some(_), Some(format)) => { (Some(_), Some(_), Some(format)) => {
let has_key = format.contains("{key}"); let has_key = format.contains("{{key}}");
let has_value = format.contains("{value}"); let has_value = format.contains("{{value}}");
if has_key && has_value { if has_key && has_value {
Ok(()) Ok(())
} else { } else {
let mut err = ValidationError::new("missing_placeholders"); 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.message = Some(Cow::Borrowed(
err.add_param(Cow::Borrowed("has_key"), &has_key); "must contain both '{{key}}' and '{{value}}' (with the '{{' and '}}' characters) in the arg_format",
err.add_param(Cow::Borrowed("has_value"), &has_value); ));
Err(err) err.add_param(Cow::Borrowed("has_key"), &has_key);
} err.add_param(Cow::Borrowed("has_value"), &has_value);
}, Err(err)
(None, None, None) => Ok(()), }
}
(None, None, None) => Ok(()),
_ => { _ => {
let mut err = ValidationError::new("both_or_none"); let mut err = ValidationError::new("both_or_none");
err.message = Some(Cow::Borrowed( 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] #[serde_as]
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
pub struct Config { pub struct Config {
@@ -93,10 +110,10 @@ impl Config {
pub fn local_provider_password_file() -> Option<PathBuf> { pub fn local_provider_password_file() -> Option<PathBuf> {
let mut path = dirs::home_dir().map(|p| p.join(".gman_password")); let mut path = dirs::home_dir().map(|p| p.join(".gman_password"));
if let Some(p) = &path { if let Some(p) = &path
if !p.exists() { && !p.exists()
path = None; {
} path = None;
} }
path path
+21 -23
View File
@@ -1,29 +1,29 @@
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{Context, Result, anyhow, bail};
use argon2::{ 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::{ use chacha20poly1305::{
aead::{Aead, KeyInit, OsRng}, Key, XChaCha20Poly1305, XNonce,
Key, XChaCha20Poly1305, XNonce, aead::{Aead, KeyInit, OsRng},
}; };
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use zeroize::Zeroize; use zeroize::Zeroize;
pub mod providers;
pub mod config; pub mod config;
pub mod providers;
pub (in crate) const HEADER: &str = "$VAULT"; pub(crate) const HEADER: &str = "$VAULT";
pub (in crate) const VERSION: &str = "v1"; pub(crate) const VERSION: &str = "v1";
pub (in crate) const KDF: &str = "argon2id"; pub(crate) const KDF: &str = "argon2id";
pub (in crate) const ARGON_M_COST_KIB: u32 = 19_456; pub(crate) const ARGON_M_COST_KIB: u32 = 19_456;
pub (in crate) const ARGON_T_COST: u32 = 2; pub(crate) const ARGON_T_COST: u32 = 2;
pub (in crate) const ARGON_P: u32 = 1; pub(crate) const ARGON_P: u32 = 1;
pub (in crate) const SALT_LEN: usize = 16; pub(crate) const SALT_LEN: usize = 16;
pub (in crate) const NONCE_LEN: usize = 24; pub(crate) const NONCE_LEN: usize = 24;
pub (in crate) const KEY_LEN: usize = 32; pub(crate) const KEY_LEN: usize = 32;
fn derive_key(password: &SecretString, salt: &[u8]) -> Result<Key> { fn derive_key(password: &SecretString, salt: &[u8]) -> Result<Key> {
let params = Params::new(ARGON_M_COST_KIB, ARGON_T_COST, ARGON_P, Some(KEY_LEN)) 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<Key> {
let mut key_bytes = [0u8; KEY_LEN]; let mut key_bytes = [0u8; KEY_LEN];
argon argon
.hash_password_into( .hash_password_into(password.expose_secret().as_bytes(), salt, &mut key_bytes)
password.expose_secret().as_bytes(),
salt,
&mut key_bytes,
)
.map_err(|e| anyhow!("argon2 into error: {:?}", e))?; .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); let key = Key::from_slice(&cloned_key_bytes);
key_bytes.zeroize(); key_bytes.zeroize();
Ok(*key) Ok(*key)
@@ -211,7 +207,9 @@ mod tests {
let env = encrypt_string(pw.clone(), msg).unwrap(); let env = encrypt_string(pw.clone(), msg).unwrap();
let mut parts: Vec<&str> = env.split(';').collect(); let mut parts: Vec<&str> = env.split(';').collect();
let ct_b64 = parts[6].strip_prefix("ct=").unwrap(); 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 ct[0] ^= 0x01; // Flip a bit
let new_ct_b64 = base64::engine::general_purpose::STANDARD.encode(&ct); let new_ct_b64 = base64::engine::general_purpose::STANDARD.encode(&ct);
let new_ct_part = format!("ct={}", new_ct_b64); let new_ct_part = format!("ct={}", new_ct_b64);
+11 -13
View File
@@ -89,7 +89,7 @@ fn resolve_git_username(git: &Path, name: Option<&String>) -> Result<String> {
return Ok(name.to_string()); 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") .with_context(|| "unable to determine git username")
} }
@@ -99,7 +99,7 @@ fn resolve_git_email(git: &Path, email: Option<&String>) -> Result<String> {
return Ok(email.to_string()); 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") .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 { if has_origin {
run_git(git, repo, &["remote", "set-url", "origin", url])?; run_git(git, repo, &["remote", "set-url", "origin", url])?;
} else { } else if Confirm::with_theme(&ColorfulTheme::default())
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?"))
.with_prompt(format!("Have you already created the remote origin '{url}' on the Git host so we can push to it?")) .default(false)
.default(false) .interact()?
.interact()? {
{ run_git(git, repo, &["remote", "add", "origin", url])?;
run_git(git, repo, &["remote", "add", "origin", url])?; } else {
} else { return Err(anyhow!("Remote origin does not yet exist. Please create remote origin before synchronizing, then try again"));
return Err(anyhow!("Remote origin does not yet exist. Please create remote origin before synchronizing, then try again")); }
}
}
Ok(()) Ok(())
} }
+4 -4
View File
@@ -54,7 +54,7 @@ impl SecretProvider for LocalProvider {
.get(key) .get(key)
.with_context(|| format!("key '{key}' not found in the vault"))?; .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)?; let plaintext = decrypt_string(&password, envelope)?;
drop(password); drop(password);
@@ -70,7 +70,7 @@ impl SecretProvider for LocalProvider {
bail!("key '{key}' already exists"); bail!("key '{key}' already exists");
} }
let password = get_password(&config)?; let password = get_password(config)?;
let envelope = encrypt_string(&password, value)?; let envelope = encrypt_string(&password, value)?;
drop(password); drop(password);
@@ -82,7 +82,7 @@ impl SecretProvider for LocalProvider {
fn update_secret(&self, config: &Config, key: &str, value: &str) -> Result<()> { fn update_secret(&self, config: &Config, key: &str, value: &str) -> Result<()> {
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default(); let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
let password = get_password(&config)?; let password = get_password(config)?;
let envelope = encrypt_string(&password, value)?; let envelope = encrypt_string(&password, value)?;
drop(password); drop(password);
@@ -317,7 +317,7 @@ fn decrypt_string(password: &SecretString, envelope: &str) -> Result<String> {
fn get_password(config: &Config) -> Result<SecretString> { fn get_password(config: &Config) -> Result<SecretString> {
if let Some(password_file) = &config.password_file { if let Some(password_file) = &config.password_file {
let password = SecretString::new( 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))? .with_context(|| format!("failed to read password file {:?}", password_file))?
.trim() .trim()
.to_string() .to_string()
+193 -149
View File
@@ -1,166 +1,210 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use gman::config::{Config, RunConfig}; use gman::config::{Config, RunConfig};
use gman::providers::local::LocalProvider; use gman::providers::SupportedProvider;
use gman::providers::SupportedProvider; use gman::providers::local::LocalProvider;
use validator::Validate; use validator::Validate;
#[test] #[test]
fn test_run_config_valid() { fn test_run_config_valid() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
arg_format: None, arg_format: None,
}; files: None,
assert!(run_config.validate().is_ok()); };
} assert!(run_config.validate().is_ok());
}
#[test] #[test]
fn test_run_config_missing_name() { fn test_run_config_missing_name() {
let run_config = RunConfig { let run_config = RunConfig {
name: None, name: None,
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
arg_format: None, arg_format: None,
}; files: None,
assert!(run_config.validate().is_err()); };
} assert!(run_config.validate().is_err());
}
#[test] #[test]
fn test_run_config_missing_secrets() { fn test_run_config_missing_secrets() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
secrets: None, secrets: None,
flag: None, flag: None,
flag_position: None, flag_position: None,
arg_format: None, arg_format: None,
}; files: None,
assert!(run_config.validate().is_err()); };
} assert!(run_config.validate().is_err());
}
#[test] #[test]
fn test_run_config_invalid_flag_position() { fn test_run_config_invalid_flag_position() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: Some(0), flag_position: Some(0),
arg_format: Some("{key}={value}".to_string()), arg_format: Some("{{key}}={{value}}".to_string()),
}; files: None,
assert!(run_config.validate().is_err()); };
} assert!(run_config.validate().is_err());
}
#[test] #[test]
fn test_run_config_flags_or_none_all_some() { fn test_run_config_flags_or_none_all_some() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: Some(1), flag_position: Some(1),
arg_format: Some("{key}={value}".to_string()), arg_format: Some("{{key}}={{value}}".to_string()),
}; files: None,
assert!(run_config.validate().is_ok()); };
} assert!(run_config.validate().is_ok());
}
#[test] #[test]
fn test_run_config_flags_or_none_all_none() { fn test_run_config_flags_or_none_all_none() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
arg_format: None, arg_format: None,
}; files: None,
assert!(run_config.validate().is_ok()); };
} assert!(run_config.validate().is_ok());
}
#[test] #[test]
fn test_run_config_flags_or_none_partial_some() { fn test_run_config_flags_or_none_partial_some() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: None, flag_position: None,
arg_format: None, arg_format: None,
}; files: None,
assert!(run_config.validate().is_err()); };
} assert!(run_config.validate().is_err());
}
#[test] #[test]
fn test_run_config_flags_or_none_missing_placeholder() { fn test_run_config_flags_or_none_missing_placeholder() {
let run_config = RunConfig { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["secret1".to_string()]),
flag: Some("--test-flag".to_string()), flag: Some("--test-flag".to_string()),
flag_position: Some(1), flag_position: Some(1),
arg_format: Some("key=value".to_string()), arg_format: Some("key=value".to_string()),
}; files: None,
assert!(run_config.validate().is_err()); };
} assert!(run_config.validate().is_err());
}
#[test] #[test]
fn test_config_valid() { fn test_run_config_flags_or_files_all_none() {
let config = Config { let run_config = RunConfig {
provider: SupportedProvider::Local(LocalProvider), name: Some("test".to_string()),
password_file: None, secrets: Some(vec!["secret1".to_string()]),
git_branch: None, flag: None,
git_remote_url: None, flag_position: None,
git_user_name: None, arg_format: None,
git_user_email: Some("test@example.com".to_string()), files: None,
git_executable: None, };
run_configs: None, assert!(run_config.validate().is_ok());
}; }
assert!(config.validate().is_ok());
}
#[test] #[test]
fn test_config_invalid_email() { fn test_run_config_flags_or_files_files_is_some() {
let config = Config { let run_config = RunConfig {
provider: SupportedProvider::Local(LocalProvider), name: Some("test".to_string()),
password_file: None, secrets: Some(vec!["secret1".to_string()]),
git_branch: None, flag: None,
git_remote_url: None, flag_position: None,
git_user_name: None, arg_format: None,
git_user_email: Some("test".to_string()), files: Some(Vec::new()),
git_executable: None, };
run_configs: None, assert!(run_config.validate().is_ok());
}; }
assert!(config.validate().is_err());
}
#[test] #[test]
fn test_config_default() { fn test_run_config_flags_or_files_all_some() {
let config = Config::default(); let run_config = RunConfig {
assert_eq!( name: Some("test".to_string()),
config.provider, secrets: Some(vec!["secret1".to_string()]),
SupportedProvider::Local(LocalProvider) flag: Some("--test-flag".to_string()),
); flag_position: Some(1),
assert_eq!(config.git_branch, Some("main".to_string())); arg_format: Some("{{key}}={{value}}".to_string()),
} files: Some(Vec::new()),
};
assert!(run_config.validate().is_err());
}
#[test] #[test]
fn test_config_extract_provider() { fn test_config_valid() {
let config = Config::default(); let config = Config {
let provider = config.extract_provider(); provider: SupportedProvider::Local(LocalProvider),
assert_eq!(provider.name(), "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] #[test]
fn test_config_local_provider_password_file() { fn test_config_invalid_email() {
let path = Config::local_provider_password_file(); let config = Config {
let expected_path = dirs::home_dir().map(|p| p.join(".gman_password")); provider: SupportedProvider::Local(LocalProvider),
if let Some(p) = &expected_path { password_file: None,
if !p.exists() { git_branch: None,
assert_eq!(path, None); git_remote_url: None,
} else { git_user_name: None,
assert_eq!(path, expected_path); git_user_email: Some("test".to_string()),
} git_executable: None,
} else { run_configs: None,
assert_eq!(path, 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);
}
}
} }
-1
View File
@@ -1,6 +1,5 @@
use gman::providers::local::LocalProviderConfig; use gman::providers::local::LocalProviderConfig;
#[test] #[test]
fn test_local_provider_config_default() { fn test_local_provider_config_default() {
let config = LocalProviderConfig::default(); let config = LocalProviderConfig::default();
+2 -5
View File
@@ -1,5 +1,5 @@
use gman::providers::{ParseProviderError, SupportedProvider};
use gman::providers::local::LocalProvider; use gman::providers::local::LocalProvider;
use gman::providers::{ParseProviderError, SupportedProvider};
use std::str::FromStr; use std::str::FromStr;
#[test] #[test]
@@ -17,10 +17,7 @@ fn test_supported_provider_from_str_valid() {
#[test] #[test]
fn test_supported_provider_from_str_invalid() { fn test_supported_provider_from_str_invalid() {
let err = SupportedProvider::from_str("invalid").unwrap_err(); let err = SupportedProvider::from_str("invalid").unwrap_err();
assert_eq!( assert_eq!(err.to_string(), "unsupported provider 'invalid'");
err.to_string(),
"unsupported provider 'invalid'"
);
} }
#[test] #[test]