Implemented support for local synchronization to a remote Git repo

This commit is contained in:
2025-09-09 12:49:35 -06:00
parent 8ac9ca40df
commit a0b710b96b
7 changed files with 471 additions and 33 deletions
Generated
+83 -1
View File
@@ -229,8 +229,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys",
"num-traits", "num-traits",
"serde", "serde",
"wasm-bindgen",
"windows-link 0.2.0", "windows-link 0.2.0",
] ]
@@ -313,6 +315,19 @@ dependencies = [
"thiserror", "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]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.7.1" version = "0.7.1"
@@ -448,6 +463,18 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c877555693c14d2f84191cfd3ad8582790fc52b5e2274b40b59cf5f5cea25c7" 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]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -486,7 +513,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users",
"windows-sys 0.60.2", "windows-sys 0.61.0",
] ]
[[package]] [[package]]
@@ -515,6 +542,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -531,6 +564,12 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.1" version = "0.1.1"
@@ -600,13 +639,16 @@ dependencies = [
"backtrace", "backtrace",
"base64", "base64",
"chacha20poly1305", "chacha20poly1305",
"chrono",
"clap", "clap",
"clap_complete", "clap_complete",
"confy", "confy",
"crossterm", "crossterm",
"dialoguer",
"dirs", "dirs",
"heck", "heck",
"human-panic", "human-panic",
"indoc",
"log", "log",
"log4rs", "log4rs",
"rpassword", "rpassword",
@@ -824,6 +866,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "indoc"
version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@@ -1482,6 +1530,12 @@ dependencies = [
"unsafe-libyaml", "unsafe-libyaml",
] ]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -1564,6 +1618,19 @@ dependencies = [
"syn", "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]] [[package]]
name = "terminal_size" name = "terminal_size"
version = "0.4.3" version = "0.4.3"
@@ -1698,6 +1765,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"
@@ -1986,6 +2059,15 @@ dependencies = [
"windows-targets 0.53.3", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
+3
View File
@@ -36,6 +36,9 @@ heck = "0.5.0"
thiserror = "2.0.16" thiserror = "2.0.16"
serde_with = "3.14.0" serde_with = "3.14.0"
serde_json = "1.0.143" serde_json = "1.0.143"
dialoguer = "0.12.0"
chrono = "0.4.42"
indoc = "2.0.6"
[[bin]] [[bin]]
bench = false bench = false
+18 -10
View File
@@ -13,6 +13,7 @@ use heck::ToSnakeCase;
use std::io::{self, IsTerminal, Read, Write}; use std::io::{self, IsTerminal, Read, Write};
use std::panic; use std::panic;
use std::panic::PanicHookInfo; use std::panic::PanicHookInfo;
use validator::Validate;
mod utils; mod utils;
@@ -89,9 +90,13 @@ enum Commands {
name: String, 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 {}, List {},
/// Sync secrets with remote storage (if supported by the provider)
Sync {},
/// Generate shell completion scripts /// Generate shell completion scripts
Completions { Completions {
/// The shell to generate the script for /// The shell to generate the script for
@@ -106,7 +111,7 @@ fn main() -> Result<()> {
panic_hook(info); panic_hook(info);
})); }));
let cli = Cli::parse(); let cli = Cli::parse();
let config = load_config(&cli)?; let mut config = load_config(&cli)?;
let secrets_provider = config.extract_provider(); let secrets_provider = config.extract_provider();
match cli.command { match cli.command {
@@ -179,20 +184,22 @@ fn main() -> Result<()> {
println!("{}", serde_json::to_string_pretty(&json_output)?); println!("{}", serde_json::to_string_pretty(&json_output)?);
return Ok(()); return Ok(());
} }
Some(OutputFormat::Text) => { Some(OutputFormat::Text) | None => {
for key in &secrets { for key in &secrets {
println!("- {}", key); println!("{}", key);
}
}
None => {
println!("Secrets in the vault:");
for key in &secrets {
println!("- {}", key);
} }
} }
} }
} }
} }
Commands::Sync {} => {
secrets_provider
.sync(&mut config)
.map(|_| match cli.output {
None => println!("✓ Secrets synchronized with remote"),
Some(_) => (),
})?;
}
Commands::Completions { shell } => { Commands::Completions { shell } => {
let mut cmd = Cli::command(); let mut cmd = Cli::command();
let bin_name = cmd.get_name().to_string(); let bin_name = cmd.get_name().to_string();
@@ -205,6 +212,7 @@ fn main() -> Result<()> {
fn load_config(cli: &Cli) -> Result<Config> { fn load_config(cli: &Cli) -> Result<Config> {
let mut config: Config = confy::load("gman", "config")?; let mut config: Config = confy::load("gman", "config")?;
config.validate()?;
if let Some(local_password_file) = Config::local_provider_password_file() { if let Some(local_password_file) = Config::local_provider_password_file() {
config.password_file = Some(local_password_file); config.password_file = Some(local_password_file);
} }
+31 -19
View File
@@ -1,17 +1,24 @@
use crate::providers::{SecretProvider, SupportedProvider}; use crate::providers::{SecretProvider, SupportedProvider};
use log::debug;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr; use serde_with::DisplayFromStr;
use serde_with::serde_as; use serde_with::serde_as;
use std::path::PathBuf; use std::path::PathBuf;
use log::{debug};
use validator::Validate; use validator::Validate;
#[serde_as] #[serde_as]
#[derive(Debug, Validate, Serialize, Deserialize)] #[derive(Debug, Clone, Validate, Serialize, Deserialize)]
pub struct Config { pub struct Config {
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
pub provider: SupportedProvider, pub provider: SupportedProvider,
pub password_file: Option<PathBuf>, pub password_file: Option<PathBuf>,
pub git_branch: Option<String>,
/// The git remote URL to push changes to (e.g. git@github.com:user/repo.git)
pub git_remote_url: Option<String>,
pub git_user_name: Option<String>,
#[validate(email)]
pub git_user_email: Option<String>,
pub git_executable: Option<PathBuf>,
} }
impl Default for Config { impl Default for Config {
@@ -19,28 +26,33 @@ impl Default for Config {
Self { Self {
provider: SupportedProvider::Local(Default::default()), provider: SupportedProvider::Local(Default::default()),
password_file: Config::local_provider_password_file(), 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 { impl Config {
pub fn extract_provider(&self) -> Box<&dyn SecretProvider> { pub fn extract_provider(&self) -> Box<dyn SecretProvider> {
match &self.provider { match &self.provider {
SupportedProvider::Local(p) => { SupportedProvider::Local(p) => {
debug!("Using local secret provider"); debug!("Using local secret provider");
Box::new(p) Box::new(*p)
} }
} }
} }
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() { if !p.exists() {
path = None; path = None;
} }
} }
path path
} }
} }
+284
View File
@@ -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<String>,
#[validate(required)]
pub branch: &'a Option<String>,
pub user_name: &'a Option<String>,
pub user_email: &'a Option<String>,
pub git_executable: &'a Option<PathBuf>,
}
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<String> {
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<String> {
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<PathBuf> {
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<String> {
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(())
}
+49 -1
View File
@@ -17,8 +17,12 @@ use chacha20poly1305::{
Key, XChaCha20Poly1305, XNonce, Key, XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, OsRng}, aead::{Aead, KeyInit, OsRng},
}; };
use dialoguer::{theme, Input};
use log::{debug, error}; use log::{debug, error};
use serde::Deserialize; use serde::Deserialize;
use theme::ColorfulTheme;
use validator::Validate;
use crate::providers::git_sync::{sync_and_push, SyncOpts};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LocalProviderConfig { pub struct LocalProviderConfig {
@@ -36,7 +40,7 @@ impl Default for LocalProviderConfig {
} }
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Clone, Copy, Default, Deserialize)]
pub struct LocalProvider; pub struct LocalProvider;
impl SecretProvider for LocalProvider { impl SecretProvider for LocalProvider {
@@ -110,6 +114,50 @@ impl SecretProvider for LocalProvider {
Ok(keys) 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<String> { fn encrypt_string(password: &SecretString, plaintext: &str) -> Result<String> {
+3 -2
View File
@@ -1,4 +1,5 @@
pub mod local; pub mod local;
mod git_sync;
use crate::config::Config; use crate::config::Config;
use crate::providers::local::LocalProvider; use crate::providers::local::LocalProvider;
@@ -14,7 +15,7 @@ pub trait SecretProvider {
fn update_secret(&self, config: &Config, key: &str, value: &str) -> Result<()>; fn update_secret(&self, config: &Config, key: &str, value: &str) -> Result<()>;
fn delete_secret(&self, key: &str) -> Result<()>; fn delete_secret(&self, key: &str) -> Result<()>;
fn list_secrets(&self) -> Result<Vec<String>>; fn list_secrets(&self) -> Result<Vec<String>>;
// fn sync(&self, config: &config) -> Result<()>; fn sync(&self, config: &mut Config) -> Result<()>;
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -23,7 +24,7 @@ pub enum ParseProviderError {
Unsupported(String), Unsupported(String),
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Clone, Copy, Deserialize)]
pub enum SupportedProvider { pub enum SupportedProvider {
Local(LocalProvider), Local(LocalProvider),
} }