diff --git a/Cargo.lock b/Cargo.lock index f75c491..d161762 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,8 +229,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.0", ] @@ -313,6 +315,19 @@ dependencies = [ "thiserror", ] +[[package]] +name = "console" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.61.0", +] + [[package]] name = "convert_case" version = "0.7.1" @@ -448,6 +463,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c877555693c14d2f84191cfd3ad8582790fc52b5e2274b40b59cf5f5cea25c7" +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -486,7 +513,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -515,6 +542,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -531,6 +564,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.1" @@ -600,13 +639,16 @@ dependencies = [ "backtrace", "base64", "chacha20poly1305", + "chrono", "clap", "clap_complete", "confy", "crossterm", + "dialoguer", "dirs", "heck", "human-panic", + "indoc", "log", "log4rs", "rpassword", @@ -824,6 +866,12 @@ dependencies = [ "serde", ] +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + [[package]] name = "inout" version = "0.1.4" @@ -1482,6 +1530,12 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.3.0" @@ -1564,6 +1618,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.60.2", +] + [[package]] name = "terminal_size" version = "0.4.3" @@ -1698,6 +1765,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1986,6 +2059,15 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 08bbaf2..ca7c7e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,9 @@ heck = "0.5.0" thiserror = "2.0.16" serde_with = "3.14.0" serde_json = "1.0.143" +dialoguer = "0.12.0" +chrono = "0.4.42" +indoc = "2.0.6" [[bin]] bench = false diff --git a/src/bin/gman/main.rs b/src/bin/gman/main.rs index 856d83e..44e6dff 100644 --- a/src/bin/gman/main.rs +++ b/src/bin/gman/main.rs @@ -13,6 +13,7 @@ use heck::ToSnakeCase; use std::io::{self, IsTerminal, Read, Write}; use std::panic; use std::panic::PanicHookInfo; +use validator::Validate; mod utils; @@ -89,9 +90,13 @@ enum Commands { name: String, }, - /// List all secrets stored in the configured secret provider + /// 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. List {}, + /// Sync secrets with remote storage (if supported by the provider) + Sync {}, + /// Generate shell completion scripts Completions { /// The shell to generate the script for @@ -106,7 +111,7 @@ fn main() -> Result<()> { panic_hook(info); })); let cli = Cli::parse(); - let config = load_config(&cli)?; + let mut config = load_config(&cli)?; let secrets_provider = config.extract_provider(); match cli.command { @@ -179,20 +184,22 @@ fn main() -> Result<()> { println!("{}", serde_json::to_string_pretty(&json_output)?); return Ok(()); } - Some(OutputFormat::Text) => { + Some(OutputFormat::Text) | None => { for key in &secrets { - println!("- {}", key); - } - } - None => { - println!("Secrets in the vault:"); - for key in &secrets { - println!("- {}", key); + println!("{}", key); } } } } } + Commands::Sync {} => { + secrets_provider + .sync(&mut config) + .map(|_| match cli.output { + None => println!("✓ Secrets synchronized with remote"), + Some(_) => (), + })?; + } Commands::Completions { shell } => { let mut cmd = Cli::command(); let bin_name = cmd.get_name().to_string(); @@ -205,6 +212,7 @@ fn main() -> Result<()> { fn load_config(cli: &Cli) -> Result { let mut config: Config = confy::load("gman", "config")?; + config.validate()?; if let Some(local_password_file) = Config::local_provider_password_file() { config.password_file = Some(local_password_file); } diff --git a/src/config.rs b/src/config.rs index 5ed9c00..35adbc6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,17 +1,24 @@ use crate::providers::{SecretProvider, SupportedProvider}; +use log::debug; use serde::{Deserialize, Serialize}; use serde_with::DisplayFromStr; use serde_with::serde_as; use std::path::PathBuf; -use log::{debug}; use validator::Validate; #[serde_as] -#[derive(Debug, Validate, Serialize, Deserialize)] +#[derive(Debug, Clone, Validate, Serialize, Deserialize)] pub struct Config { #[serde_as(as = "DisplayFromStr")] 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) + pub git_remote_url: Option, + pub git_user_name: Option, + #[validate(email)] + pub git_user_email: Option, + pub git_executable: Option, } impl Default for Config { @@ -19,28 +26,33 @@ impl Default for Config { Self { provider: SupportedProvider::Local(Default::default()), password_file: Config::local_provider_password_file(), + git_branch: Some("main".into()), + git_remote_url: None, + git_user_name: None, + git_user_email: None, + git_executable: None, } } } impl Config { - pub fn extract_provider(&self) -> Box<&dyn SecretProvider> { - match &self.provider { - SupportedProvider::Local(p) => { - debug!("Using local secret provider"); - Box::new(p) - } - } - } + pub fn extract_provider(&self) -> Box { + match &self.provider { + SupportedProvider::Local(p) => { + debug!("Using local secret provider"); + Box::new(*p) + } + } + } - 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; - } - } + 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; + } + } - path - } + path + } } diff --git a/src/providers/git_sync.rs b/src/providers/git_sync.rs new file mode 100644 index 0000000..eca2e43 --- /dev/null +++ b/src/providers/git_sync.rs @@ -0,0 +1,284 @@ +use anyhow::{Context, Result, anyhow}; +use chrono::Utc; +use dialoguer::Confirm; +use dialoguer::theme::ColorfulTheme; +use indoc::formatdoc; +use log::debug; +use std::env; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use validator::Validate; + +#[derive(Debug, Validate, Clone)] +pub struct SyncOpts<'a> { + #[validate(required)] + pub remote_url: &'a Option, + #[validate(required)] + pub branch: &'a Option, + pub user_name: &'a Option, + pub user_email: &'a Option, + pub git_executable: &'a Option, +} + +pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> { + debug!("Syncing with git: {:?}", opts); + opts.validate() + .with_context(|| "invalid git sync options")?; + let commit_message = format!("chore: sync @ {}", Utc::now().to_rfc3339()); + let repo_dir = confy::get_configuration_file_path("gman", "vault") + .with_context(|| "get config dir")? + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| anyhow!("Failed to determine repo dir"))?; + std::fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?; + + let git = resolve_git(opts.git_executable.as_ref())?; + ensure_git_available(&git)?; + + let username = resolve_git_username(&git, opts.user_name.as_ref())? + .trim() + .to_string(); + let email = resolve_git_email(&git, opts.user_email.as_ref())? + .trim() + .to_string(); + let branch = opts.branch.as_ref().expect("no target branch defined"); + let remote_url = opts.remote_url.as_ref().expect("no remote url defined"); + + debug!( + "{}", + formatdoc!( + r#" + Using repo dir: {} + git executable: {} + git user: {} + git user email: {} + git remote: {}"#, + repo_dir.display(), + git.display(), + username, + email, + remote_url + ) + ); + + init_repo_if_needed(&git, &repo_dir, branch)?; + set_local_identity(&git, &repo_dir, username, email)?; + checkout_branch(&git, &repo_dir, branch)?; + set_origin(&git, &repo_dir, remote_url)?; + + stage_all(&git, &repo_dir)?; + + fetch_and_pull(&git, &repo_dir, branch)?; + + commit_now(&git, &repo_dir, &commit_message)?; + + run_git( + &git, + &repo_dir, + &["push", "-u", "origin", "--force", branch], + )?; + + run_git(&git, &repo_dir, &["remote", "set-head", "origin", "-a"]) + .with_context(|| "Failed to set remote HEAD") +} + +fn resolve_git_username(git: &Path, name: Option<&String>) -> Result { + debug!("Resolving git username"); + + if let Some(name) = name { + return Ok(name.to_string()); + } + + run_git_config_capture(&git, &["config", "user.name"]) + .with_context(|| "unable to determine git username") +} + +fn resolve_git_email(git: &Path, email: Option<&String>) -> Result { + debug!("Resolving git user email"); + if let Some(email) = email { + return Ok(email.to_string()); + } + + run_git_config_capture(&git, &["config", "user.email"]) + .with_context(|| "unable to determine git user email") +} + +fn resolve_git(override_path: Option<&PathBuf>) -> Result { + debug!("Resolving git executable"); + if let Some(p) = override_path { + return Ok(p.to_path_buf()); + } + if let Ok(s) = env::var("GIT_EXECUTABLE") { + return Ok(PathBuf::from(s)); + } + Ok(PathBuf::from("git")) +} + +fn ensure_git_available(git: &Path) -> Result<()> { + let ok = Command::new(git) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("run git --version")? + .success(); + if !ok { + Err(anyhow!("`git` not available on PATH")) + } else { + Ok(()) + } +} + +fn run_git(git: &Path, repo: &Path, args: &[&str]) -> Result<()> { + let status = Command::new(git) + .arg("-C") + .arg(repo) + .args(args) + .status() + .with_context(|| format!("git {}", args.join(" ")))?; + if !status.success() { + return Err(anyhow!("git failed: {}", args.join(" "))); + } + Ok(()) +} + +fn run_git_config_capture(git: &Path, args: &[&str]) -> Result { + let out = Command::new(git) + .args(args) + .output() + .with_context(|| format!("git {}", args.join(" ")))?; + + if !out.status.success() { + return Err(anyhow!( + "git failed (exit {}): {}", + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stderr) + )); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + +fn init_repo_if_needed(git: &Path, repo: &Path, branch: &str) -> Result<()> { + let inside = Command::new(git) + .arg("-C") + .arg(repo) + .args(["rev-parse", "--git-dir"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + if !inside { + run_git( + git, + repo, + &["-c", &format!("init.defaultBranch={branch}"), "init"], + )?; + } else { + let _ = run_git( + git, + repo, + &["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")], + ); + } + Ok(()) +} + +fn set_local_identity(git: &Path, repo: &Path, username: String, email: String) -> Result<()> { + run_git(git, repo, &["config", "user.name", &username])?; + run_git(git, repo, &["config", "user.email", &email])?; + + Ok(()) +} + +fn checkout_branch(git: &Path, repo: &Path, branch: &str) -> Result<()> { + run_git(git, repo, &["checkout", "-B", branch])?; + Ok(()) +} + +fn set_origin(git: &Path, repo: &Path, url: &str) -> Result<()> { + let has_origin = Command::new(git) + .arg("-C") + .arg(repo) + .args(["remote", "get-url", "origin"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + 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")); + } + } + Ok(()) +} + +fn stage_all(git: &Path, repo: &Path) -> Result<()> { + run_git(git, repo, &["add", "-A"])?; + Ok(()) +} + +fn fetch_and_pull(git: &Path, repo: &Path, branch: &str) -> Result<()> { + run_git(git, repo, &["fetch", "origin", branch]) + .with_context(|| "Failed to fetch changes from remote")?; + run_git( + git, + repo, + &["merge", "--ff-only", &format!("origin/{branch}")], + ) + .with_context(|| "Failed to merge remote changes")?; + Ok(()) +} + +fn has_head(git: &Path, repo: &Path) -> bool { + Command::new(git) + .arg("-C") + .arg(repo) + .args(["rev-parse", "--verify", "HEAD"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn commit_now(git: &Path, repo: &Path, msg: &str) -> Result<()> { + let staged_changed = Command::new(git) + .arg("-C") + .arg(repo) + .args(["diff", "--cached", "--quiet", "--exit-code"]) + .status() + .context("git diff --cached")? + .code() + .map(|c| c == 1) + .unwrap_or(false); + + if staged_changed { + run_git(git, repo, &["commit", "-m", msg])?; + return Ok(()); + } + + let unborn = !has_head(git, repo); + + if unborn { + run_git( + git, + repo, + &["commit", "--allow-empty", "-m", "initial sync commit"], + )?; + return Ok(()); + } + + Ok(()) +} diff --git a/src/providers/local.rs b/src/providers/local.rs index 3f2f01c..fd0128c 100644 --- a/src/providers/local.rs +++ b/src/providers/local.rs @@ -17,8 +17,12 @@ use chacha20poly1305::{ Key, XChaCha20Poly1305, XNonce, aead::{Aead, KeyInit, OsRng}, }; +use dialoguer::{theme, Input}; use log::{debug, error}; use serde::Deserialize; +use theme::ColorfulTheme; +use validator::Validate; +use crate::providers::git_sync::{sync_and_push, SyncOpts}; #[derive(Debug, Clone)] pub struct LocalProviderConfig { @@ -36,7 +40,7 @@ impl Default for LocalProviderConfig { } } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Clone, Copy, Default, Deserialize)] pub struct LocalProvider; impl SecretProvider for LocalProvider { @@ -110,6 +114,50 @@ impl SecretProvider for LocalProvider { Ok(keys) } + + fn sync(&self, config: &mut Config) -> Result<()> { + let mut config_changed = false; + + if config.git_branch.is_none() { + config_changed = true; + debug!("Prompting user to set git_branch in config for sync"); + let branch: String = Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Enter git branch to sync with") + .default("main".into()) + .interact_text()?; + + config.git_branch = Some(branch); + } + + if config.git_remote_url.is_none() { + config_changed = true; + debug!("Prompting user to set git_remote in config for sync"); + let remote: String = Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Enter remote git URL to sync with") + .validate_with(|s: &String| { + Config { git_remote_url: Some(s.clone()), ..Config::default() }.validate().map(|_| ()) + .map_err(|e| e.to_string()) + }) + .interact_text()?; + + config.git_remote_url = Some(remote); + } + + if config_changed { + debug!("Saving updated config"); + confy::store("gman", "config", &config).with_context(|| "failed to save updated config")?; + } + + let sync_opts = SyncOpts { + remote_url: &config.git_remote_url, + branch: &config.git_branch, + user_name: &config.git_user_name, + user_email: &config.git_user_email, + git_executable: &config.git_executable + }; + + sync_and_push(&sync_opts) + } } fn encrypt_string(password: &SecretString, plaintext: &str) -> Result { diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 64c0cac..560d14a 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1,4 +1,5 @@ pub mod local; +mod git_sync; use crate::config::Config; use crate::providers::local::LocalProvider; @@ -14,7 +15,7 @@ pub trait SecretProvider { fn update_secret(&self, config: &Config, key: &str, value: &str) -> Result<()>; fn delete_secret(&self, key: &str) -> Result<()>; fn list_secrets(&self) -> Result>; - // fn sync(&self, config: &config) -> Result<()>; + fn sync(&self, config: &mut Config) -> Result<()>; } #[derive(Debug, Error)] @@ -23,7 +24,7 @@ pub enum ParseProviderError { Unsupported(String), } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Copy, Deserialize)] pub enum SupportedProvider { Local(LocalProvider), }