Updated the README with a more clear example of what the purpose of gman is, and also support fully isolated, multiple local based configurations with isolated git repositories so users can have separate repos for different environments

This commit is contained in:
2025-09-11 16:21:47 -06:00
parent a8d959dac3
commit 9e8e317daf
10 changed files with 228 additions and 84 deletions
+41 -8
View File
@@ -4,7 +4,7 @@ use dialoguer::Confirm;
use dialoguer::theme::ColorfulTheme;
use indoc::formatdoc;
use log::debug;
use std::env;
use std::{env, fs};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use validator::Validate;
@@ -25,12 +25,28 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
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")
let config_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()))?;
.ok_or_else(|| anyhow!("Failed to determine config dir"))?;
let remote_url = opts.remote_url.as_ref().expect("no remote url defined");
let repo_name = repo_name_from_url(remote_url);
let repo_dir = config_dir.join(format!(".{}", repo_name));
fs::create_dir_all(&repo_dir).with_context(|| format!("create {}", repo_dir.display()))?;
// Move the default vault into the repo dir on first sync so only vault.yml is tracked.
let default_vault = confy::get_configuration_file_path("gman", "vault")
.with_context(|| "get default vault path")?;
let repo_vault = repo_dir.join("vault.yml");
if default_vault.exists() && !repo_vault.exists() {
fs::rename(&default_vault, &repo_vault)
.with_context(|| format!("move {} -> {}", default_vault.display(), repo_vault.display()))?;
} else if !repo_vault.exists() {
// Ensure an empty vault exists to allow initial commits
fs::write(&repo_vault, "{}\n").with_context(|| format!("create {}", repo_vault.display()))?;
}
let git = resolve_git(opts.git_executable.as_ref())?;
ensure_git_available(&git)?;
@@ -42,7 +58,6 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
.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!(
"{}",
@@ -74,7 +89,7 @@ pub fn sync_and_push(opts: &SyncOpts<'_>) -> Result<()> {
// Stage and commit any subsequent local changes after aligning with remote
// so we don't merge uncommitted local state.
stage_all(&git, &repo_dir)?;
stage_vault_only(&git, &repo_dir)?;
commit_now(&git, &repo_dir, &commit_message)?;
@@ -228,8 +243,8 @@ fn set_origin(git: &Path, repo: &Path, url: &str) -> Result<()> {
Ok(())
}
fn stage_all(git: &Path, repo: &Path) -> Result<()> {
run_git(git, repo, &["add", "-A"])?;
fn stage_vault_only(git: &Path, repo: &Path) -> Result<()> {
run_git(git, repo, &["add", "vault.yml"])?;
Ok(())
}
@@ -321,6 +336,16 @@ fn commit_now(git: &Path, repo: &Path, msg: &str) -> Result<()> {
Ok(())
}
pub fn repo_name_from_url(url: &str) -> String {
let mut s = url;
if let Some(idx) = s.rfind('/') {
s = &s[idx + 1..];
} else if let Some(idx) = s.rfind(':') {
s = &s[idx + 1..];
}
s.trim_end_matches(".git").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -370,4 +395,12 @@ mod tests {
env::remove_var("GIT_EXECUTABLE");
}
}
#[test]
fn test_repo_name_from_url() {
assert_eq!(repo_name_from_url("git@github.com:user/vault.git"), "vault");
assert_eq!(repo_name_from_url("https://github.com/user/test-vault.git"), "test-vault");
assert_eq!(repo_name_from_url("ssh://git@example.com/x/y/z.git"), "z");
assert_eq!(repo_name_from_url("git@example.com:ns/repo"), "repo");
}
}
+66 -12
View File
@@ -2,10 +2,11 @@ use anyhow::{anyhow, bail, Context};
use secrecy::{ExposeSecret, SecretString};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use zeroize::Zeroize;
use crate::config::ProviderConfig;
use crate::providers::git_sync::{sync_and_push, SyncOpts};
use crate::providers::git_sync::{repo_name_from_url, sync_and_push, SyncOpts};
use crate::providers::SecretProvider;
use crate::{
ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION,
@@ -69,7 +70,8 @@ impl SecretProvider for LocalProvider {
}
fn get_secret(&self, config: &ProviderConfig, key: &str) -> Result<String> {
let vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
let vault_path = active_vault_path(config)?;
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let envelope = vault
.get(key)
.with_context(|| format!("key '{key}' not found in the vault"))?;
@@ -82,7 +84,8 @@ impl SecretProvider for LocalProvider {
}
fn set_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> {
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
let vault_path = active_vault_path(config)?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if vault.contains_key(key) {
error!(
"Key '{key}' already exists in the vault. Use a different key or delete the existing one first."
@@ -96,11 +99,12 @@ impl SecretProvider for LocalProvider {
vault.insert(key.to_string(), envelope);
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault")
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
}
fn update_secret(&self, config: &ProviderConfig, key: &str, value: &str) -> Result<()> {
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
let vault_path = active_vault_path(config)?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let password = get_password(config)?;
let envelope = encrypt_string(&password, value)?;
@@ -113,27 +117,29 @@ impl SecretProvider for LocalProvider {
.with_context(|| format!("key '{key}' not found in the vault"))?;
*vault_entry = envelope;
return confy::store("gman", "vault", vault)
return store_vault(&vault_path, &vault)
.with_context(|| "failed to save secret to the vault");
}
vault.insert(key.to_string(), envelope);
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault")
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
}
fn delete_secret(&self, key: &str) -> Result<()> {
let mut vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
fn delete_secret(&self, config: &ProviderConfig, key: &str) -> Result<()> {
let vault_path = active_vault_path(config)?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if !vault.contains_key(key) {
error!("Key '{key}' does not exist in the vault.");
bail!("key '{key}' does not exist");
}
vault.remove(key);
confy::store("gman", "vault", vault).with_context(|| "failed to save secret to the vault")
store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
}
fn list_secrets(&self) -> Result<Vec<String>> {
let vault: HashMap<String, String> = confy::load("gman", "vault").unwrap_or_default();
fn list_secrets(&self, config: &ProviderConfig) -> Result<Vec<String>> {
let vault_path = active_vault_path(config)?;
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let keys: Vec<String> = vault.keys().cloned().collect();
Ok(keys)
@@ -190,6 +196,54 @@ impl SecretProvider for LocalProvider {
}
}
fn default_vault_path() -> Result<PathBuf> {
confy::get_configuration_file_path("gman", "vault").with_context(|| "get config dir")
}
fn base_config_dir() -> Result<PathBuf> {
default_vault_path()?
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| anyhow!("Failed to determine config dir"))
}
fn repo_dir_for_config(config: &ProviderConfig) -> Result<Option<PathBuf>> {
if let Some(remote) = &config.git_remote_url {
let name = repo_name_from_url(remote);
let dir = base_config_dir()?.join(format!(".{}", name));
Ok(Some(dir))
} else {
Ok(None)
}
}
fn active_vault_path(config: &ProviderConfig) -> Result<PathBuf> {
if let Some(dir) = repo_dir_for_config(config)?
&& dir.exists()
{
return Ok(dir.join("vault.yml"));
}
default_vault_path()
}
fn load_vault(path: &Path) -> Result<HashMap<String, String>> {
if !path.exists() {
return Ok(HashMap::new());
}
let s = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let map: HashMap<String, String> = serde_yaml::from_str(&s).unwrap_or_default();
Ok(map)
}
fn store_vault(path: &Path, map: &HashMap<String, String>) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let s = serde_yaml::to_string(map).with_context(|| "serialize vault")?;
fs::write(path, s).with_context(|| format!("write {}", path.display()))
}
fn encrypt_string(password: &SecretString, plaintext: &str) -> Result<String> {
let mut salt = [0u8; SALT_LEN];
OsRng.fill_bytes(&mut salt);
+2 -2
View File
@@ -34,8 +34,8 @@ pub trait SecretProvider {
self.name()
))
}
fn delete_secret(&self, key: &str) -> Result<()>;
fn list_secrets(&self) -> Result<Vec<String>> {
fn delete_secret(&self, config: &ProviderConfig, key: &str) -> Result<()>;
fn list_secrets(&self, _config: &ProviderConfig) -> Result<Vec<String>> {
Err(anyhow!(
"list secrets is not supported for the provider {}",
self.name()