From 944774c1644d0a601084bbe740433715b519fc3d Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 10:44:22 +0200 Subject: [PATCH 01/19] feat: add config module for global keyring path --- src/config.rs | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 2 files changed, 219 insertions(+) create mode 100644 src/config.rs diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..25ddc2a --- /dev/null +++ b/src/config.rs @@ -0,0 +1,218 @@ +#![allow(dead_code)] + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::error::GitVeilError; + +/// Resolve the platform-appropriate config directory for gitveil. +/// +/// - Linux/macOS: `$XDG_CONFIG_HOME/gitveil` or `~/.config/gitveil` +/// - Windows: `%APPDATA%\gitveil` +pub fn config_dir() -> Result { + let base = config_base_dir()?; + Ok(base.join("gitveil")) +} + +/// Get the platform base config directory. +fn config_base_dir() -> Result { + // Check XDG_CONFIG_HOME first (all platforms, for testability) + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + let p = PathBuf::from(xdg); + if p.is_absolute() { + return Ok(p); + } + } + + #[cfg(target_os = "windows")] + { + if let Ok(appdata) = std::env::var("APPDATA") { + return Ok(PathBuf::from(appdata)); + } + } + + // Fall back to ~/.config + home_dir() + .map(|h| h.join(".config")) + .ok_or_else(|| GitVeilError::Other("cannot determine home directory".into())) +} + +/// Get the user's home directory. +fn home_dir() -> Option { + #[cfg(unix)] + { + std::env::var("HOME").ok().map(PathBuf::from) + } + #[cfg(windows)] + { + std::env::var("USERPROFILE").ok().map(PathBuf::from) + } +} + +/// Path to the config file within the config directory. +pub fn config_file_path() -> Result { + Ok(config_dir()?.join("config")) +} + +/// Load the configured keyring path from the config file. +/// Returns `Ok(None)` if no config file exists. +/// Returns `Err` if the config file exists but the path is invalid. +pub fn load_keyring_path() -> Result, GitVeilError> { + let cf = config_file_path()?; + if !cf.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&cf) + .map_err(|e| GitVeilError::Other(format!("failed to read config file: {}", e)))?; + + let path_str = content.trim(); + if path_str.is_empty() { + return Ok(None); + } + + let path = PathBuf::from(path_str); + + // Re-validate on every load + if !path.exists() { + return Err(GitVeilError::Other(format!( + "configured keyring path no longer exists: {}", + path.display() + ))); + } + if !path.is_dir() { + return Err(GitVeilError::Other(format!( + "configured keyring path is not a directory: {}", + path.display() + ))); + } + + Ok(Some(path)) +} + +/// Save a keyring path to the config file. +/// Validates the path, canonicalizes it, and writes with restrictive permissions. +pub fn save_keyring_path(path: &Path) -> Result<(), GitVeilError> { + if !path.exists() { + return Err(GitVeilError::Other(format!( + "path does not exist: {}", + path.display() + ))); + } + if !path.is_dir() { + return Err(GitVeilError::Other(format!( + "path is not a directory: {}", + path.display() + ))); + } + + // Canonicalize to resolve symlinks and relative components + let canonical = fs::canonicalize(path) + .map_err(|e| GitVeilError::Other(format!("failed to resolve path: {}", e)))?; + + // After resolving, verify it's still a directory (symlink might point to a file) + if !canonical.is_dir() { + return Err(GitVeilError::Other(format!( + "path resolves to a non-directory: {}", + canonical.display() + ))); + } + + let dir = config_dir()?; + create_config_dir(&dir)?; + + let cf = dir.join("config"); + write_config_file(&cf, canonical.to_string_lossy().as_ref())?; + + Ok(()) +} + +/// Remove the keyring path configuration. +pub fn remove_keyring_path() -> Result<(), GitVeilError> { + let cf = config_file_path()?; + if cf.exists() { + fs::remove_file(&cf) + .map_err(|e| GitVeilError::Other(format!("failed to remove config file: {}", e)))?; + } + Ok(()) +} + +/// Create the config directory with restrictive permissions. +fn create_config_dir(dir: &Path) -> Result<(), GitVeilError> { + fs::create_dir_all(dir)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; + } + + Ok(()) +} + +/// Write content to the config file with restrictive permissions. +fn write_config_file(path: &Path, content: &str) -> Result<(), GitVeilError> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::fs::PermissionsExt; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.write_all(content.as_bytes())?; + // Enforce permissions even if file pre-existed + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + } + + #[cfg(not(unix))] + { + fs::write(path, content)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_config_dir_uses_xdg() { + let tmp = TempDir::new().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", tmp.path()); + let dir = config_dir().unwrap(); + assert_eq!(dir, tmp.path().join("gitveil")); + std::env::remove_var("XDG_CONFIG_HOME"); + } + + #[test] + fn test_save_load_roundtrip() { + let tmp_config = TempDir::new().unwrap(); + let tmp_keyring = TempDir::new().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", tmp_config.path()); + + save_keyring_path(tmp_keyring.path()).unwrap(); + let loaded = load_keyring_path().unwrap(); + assert!(loaded.is_some()); + let loaded_path = loaded.unwrap(); + // Canonicalize both for comparison (macOS /private/var vs /var) + let expected = fs::canonicalize(tmp_keyring.path()).unwrap(); + assert_eq!(loaded_path, expected); + + std::env::remove_var("XDG_CONFIG_HOME"); + } + + #[test] + fn test_load_missing_config_returns_none() { + let tmp = TempDir::new().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", tmp.path()); + let loaded = load_keyring_path().unwrap(); + assert!(loaded.is_none()); + std::env::remove_var("XDG_CONFIG_HOME"); + } +} diff --git a/src/main.rs b/src/main.rs index 5005e2a..49ca245 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod cli; mod commands; +mod config; mod constants; mod crypto; mod error; From beb77d9700580637e056d570b033158acce6566f Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 10:47:34 +0200 Subject: [PATCH 02/19] feat: add config CLI subcommand (set-keyring, unset-keyring, show) --- src/cli.rs | 24 +++++++++++++++++++++++ src/commands/config.rs | 43 ++++++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/config.rs | 2 -- src/main.rs | 8 ++++++++ 5 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/commands/config.rs diff --git a/src/cli.rs b/src/cli.rs index f9b42f6..f96d3f3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -124,6 +124,12 @@ pub enum Commands { key_name: Option, }, + /// Manage global gitveil configuration + Config { + #[command(subcommand)] + action: ConfigAction, + }, + /// Generate shell completions for bash, zsh, or fish Completions { /// Shell to generate completions for @@ -161,6 +167,24 @@ pub enum Commands { }, } +#[derive(Subcommand)] +pub enum ConfigAction { + /// Set the global GPG keyring directory + #[command(name = "set-keyring")] + SetKeyring { + /// Path to a directory containing GPG public key files + #[arg()] + path: PathBuf, + }, + + /// Remove the global keyring directory setting + #[command(name = "unset-keyring")] + UnsetKeyring, + + /// Show current configuration + Show, +} + /// Generate shell completions and write to stdout. pub fn print_completions(shell: Shell) { clap_complete::generate( diff --git a/src/commands/config.rs b/src/commands/config.rs new file mode 100644 index 0000000..fa70118 --- /dev/null +++ b/src/commands/config.rs @@ -0,0 +1,43 @@ +use std::path::Path; + +use colored::Colorize; + +use crate::config; +use crate::error::GitVeilError; + +/// Set the global GPG keyring directory. +pub fn config_set_keyring(path: &Path) -> Result<(), GitVeilError> { + config::save_keyring_path(path)?; + + // Re-load to show the canonicalized path + let canonical = config::load_keyring_path()?.unwrap_or_default(); + eprintln!( + "{} keyring path: {}", + "Set".green().bold(), + canonical.display() + ); + Ok(()) +} + +/// Remove the global GPG keyring directory setting. +pub fn config_unset_keyring() -> Result<(), GitVeilError> { + config::remove_keyring_path()?; + eprintln!("{} keyring path.", "Removed".green().bold()); + Ok(()) +} + +/// Show current configuration. +pub fn config_show() -> Result<(), GitVeilError> { + match config::load_keyring_path() { + Ok(Some(path)) => { + println!("keyring-path: {}", path.display()); + } + Ok(None) => { + println!("keyring-path: (not set)"); + } + Err(e) => { + println!("keyring-path: (error: {})", e); + } + } + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 231fb54..7551b02 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod add_gpg_user; +pub mod config; pub mod export_key; pub mod init; pub mod lock; diff --git a/src/config.rs b/src/config.rs index 25ddc2a..a158e50 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use std::fs; use std::path::{Path, PathBuf}; diff --git a/src/main.rs b/src/main.rs index 49ca245..f9fc134 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,6 +71,14 @@ fn main() { commands::ls_gpg_users::ls_gpg_users(key_name.as_deref()) } + Commands::Config { action } => match action { + cli::ConfigAction::SetKeyring { path } => { + commands::config::config_set_keyring(&path) + } + cli::ConfigAction::UnsetKeyring => commands::config::config_unset_keyring(), + cli::ConfigAction::Show => commands::config::config_show(), + }, + Commands::Completions { shell } => { cli::print_completions(shell); Ok(()) From 4f299a2ff69df0cd769717fc0a64c64836ef2f20 Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 10:48:29 +0200 Subject: [PATCH 03/19] fix: skip symlinks in GPG key directory scan --- src/gpg/import.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/gpg/import.rs b/src/gpg/import.rs index 09d2cef..2dcbf94 100644 --- a/src/gpg/import.rs +++ b/src/gpg/import.rs @@ -203,6 +203,7 @@ pub fn pick_keys(keys: &[GpgKeyInfo]) -> Result, GitVeilError> { } /// Recursively scan a directory for GPG key files. +/// Symlinks are skipped to prevent symlink-based attacks (see CLAUDE.md security policy). fn scan_dir_recursive(dir: &Path, keys: &mut Vec) -> Result<(), GitVeilError> { let entries = std::fs::read_dir(dir)?; @@ -211,6 +212,12 @@ fn scan_dir_recursive(dir: &Path, keys: &mut Vec) -> Result<(), GitV Ok(ft) => ft, Err(_) => continue, }; + + // Skip symlinks (security: prevent following malicious symlinks) + if ft.is_symlink() { + continue; + } + let path = entry.path(); if ft.is_dir() { From 2c9930f0d9f13fe2f3aff85b0541d80c52e07c78 Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 10:49:34 +0200 Subject: [PATCH 04/19] feat: add-gpg-user falls back to global keyring when no args given --- src/commands/add_gpg_user.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/commands/add_gpg_user.rs b/src/commands/add_gpg_user.rs index 79df250..b0f1125 100644 --- a/src/commands/add_gpg_user.rs +++ b/src/commands/add_gpg_user.rs @@ -3,6 +3,7 @@ use std::process::Command; use colored::Colorize; +use crate::config; use crate::constants::DEFAULT_KEY_NAME; use crate::error::GitVeilError; use crate::git::repo::{find_git_dir, find_repo_root, git_crypt_dir, key_path}; @@ -47,9 +48,33 @@ pub fn add_gpg_user( } } None => { + // Try global keyring if no --from and no gpg_user_id + if gpg_user_id.is_none() { + match config::load_keyring_path() { + Ok(Some(keyring_path)) => { + return add_from_path( + key_name, + no_commit, + trusted, + &keyring_path, + &git_dir, + ); + } + Ok(None) => {} // No keyring configured, fall through to error + Err(e) => { + eprintln!( + "{} global keyring: {}", + "Warning:".yellow().bold(), + e + ); + // Fall through to error + } + } + } + let gpg_user_id = gpg_user_id.ok_or_else(|| { GitVeilError::Other( - "GPG user ID is required (or use --from to import from a file/directory/URL)" + "GPG user ID is required (or use --from to import from a file/directory/URL, or configure a global keyring with 'gitveil config set-keyring ')" .into(), ) })?; From b80184bf18ce51535621cebd5c5ccdda7fc6551e Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 10:53:45 +0200 Subject: [PATCH 05/19] test: add comprehensive integration tests for config and keyring feature --- CONTRIBUTING.md | 2 +- src/commands/add_gpg_user.rs | 6 +- src/main.rs | 4 +- tests/integration.rs | 646 +++++++++++++++++++++++++++++++++++ 4 files changed, 649 insertions(+), 9 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d2f7ab..5231414 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ cargo build cargo test ``` -All 54 tests should pass (28 unit + 20 integration + 6 cross-compatibility). They cover: +All 74 tests should pass (28 unit + 40 integration + 6 cross-compatibility). They cover: - AES-256-CTR encryption/decryption round-trips - HMAC-SHA1 known-answer vectors - Key file TLV serialization/deserialization diff --git a/src/commands/add_gpg_user.rs b/src/commands/add_gpg_user.rs index b0f1125..c002c40 100644 --- a/src/commands/add_gpg_user.rs +++ b/src/commands/add_gpg_user.rs @@ -62,11 +62,7 @@ pub fn add_gpg_user( } Ok(None) => {} // No keyring configured, fall through to error Err(e) => { - eprintln!( - "{} global keyring: {}", - "Warning:".yellow().bold(), - e - ); + eprintln!("{} global keyring: {}", "Warning:".yellow().bold(), e); // Fall through to error } } diff --git a/src/main.rs b/src/main.rs index f9fc134..e9d941b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,9 +72,7 @@ fn main() { } Commands::Config { action } => match action { - cli::ConfigAction::SetKeyring { path } => { - commands::config::config_set_keyring(&path) - } + cli::ConfigAction::SetKeyring { path } => commands::config::config_set_keyring(&path), cli::ConfigAction::UnsetKeyring => commands::config::config_unset_keyring(), cli::ConfigAction::Show => commands::config::config_show(), }, diff --git a/tests/integration.rs b/tests/integration.rs index 60c3acc..e57f748 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -740,3 +740,649 @@ fn test_lock_many_files_no_deadlock() { "secret should be encrypted after lock" ); } + +// ─── Config Tests ────────────────────────────────────────────── + +/// Run gitveil with a custom XDG_CONFIG_HOME for isolated config testing. +fn gitveil_with_config_home(config_home: &Path, dir: &Path, args: &[&str]) -> Output { + Command::new(gitveil_bin()) + .args(args) + .current_dir(dir) + .env("XDG_CONFIG_HOME", config_home) + .output() + .unwrap_or_else(|e| panic!("failed to run gitveil {:?}: {}", args, e)) +} + +#[test] +fn test_config_set_keyring_valid_directory() { + let config_home = tempfile::tempdir().unwrap(); + let keyring_dir = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &[ + "config", + "set-keyring", + &keyring_dir.path().to_string_lossy(), + ], + ); + assert_success(&out, "config set-keyring"); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Set"), + "should confirm keyring was set: {}", + stderr + ); + + // Config file should exist + let config_file = config_home.path().join("gitveil").join("config"); + assert!(config_file.exists(), "config file should be created"); +} + +#[test] +fn test_config_set_keyring_nonexistent_path_fails() { + let config_home = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &[ + "config", + "set-keyring", + "/nonexistent/path/that/does/not/exist", + ], + ); + assert!(!out.status.success(), "should fail for nonexistent path"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("does not exist"), + "should mention path doesn't exist: {}", + stderr + ); +} + +#[test] +fn test_config_set_keyring_file_not_dir_fails() { + let config_home = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + let file = work_dir.path().join("not-a-dir.txt"); + fs::write(&file, "hello").unwrap(); + + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &["config", "set-keyring", &file.to_string_lossy()], + ); + assert!(!out.status.success(), "should fail when path is a file"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("not a directory"), + "should say not a directory: {}", + stderr + ); +} + +#[test] +fn test_config_set_keyring_show_roundtrip() { + let config_home = tempfile::tempdir().unwrap(); + let keyring_dir = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + // Set + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &[ + "config", + "set-keyring", + &keyring_dir.path().to_string_lossy(), + ], + ); + assert_success(&out, "config set-keyring"); + + // Show + let out = gitveil_with_config_home(config_home.path(), work_dir.path(), &["config", "show"]); + assert_success(&out, "config show"); + let stdout = String::from_utf8_lossy(&out.stdout); + // The stored path is canonicalized, so compare canonical forms + let expected = fs::canonicalize(keyring_dir.path()).unwrap(); + assert!( + stdout.contains(&expected.to_string_lossy().to_string()), + "show should display keyring path.\nExpected to contain: {}\nGot: {}", + expected.display(), + stdout + ); +} + +#[test] +fn test_config_unset_keyring() { + let config_home = tempfile::tempdir().unwrap(); + let keyring_dir = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + // Set + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &[ + "config", + "set-keyring", + &keyring_dir.path().to_string_lossy(), + ], + ); + assert_success(&out, "config set-keyring"); + + // Unset + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &["config", "unset-keyring"], + ); + assert_success(&out, "config unset-keyring"); + + // Show should report not set + let out = gitveil_with_config_home(config_home.path(), work_dir.path(), &["config", "show"]); + assert_success(&out, "config show after unset"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("not set"), + "should say not set after unset: {}", + stdout + ); +} + +#[test] +fn test_config_show_no_config() { + let config_home = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + let out = gitveil_with_config_home(config_home.path(), work_dir.path(), &["config", "show"]); + assert_success(&out, "config show with no config"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("not set"), + "should say not set when no config exists: {}", + stdout + ); +} + +#[cfg(unix)] +#[test] +fn test_config_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let config_home = tempfile::tempdir().unwrap(); + let keyring_dir = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &[ + "config", + "set-keyring", + &keyring_dir.path().to_string_lossy(), + ], + ); + assert_success(&out, "config set-keyring"); + + let config_file = config_home.path().join("gitveil").join("config"); + let mode = fs::metadata(&config_file).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "config file should be 0o600, got 0o{:o}", mode); +} + +#[cfg(unix)] +#[test] +fn test_config_dir_permissions() { + use std::os::unix::fs::PermissionsExt; + + let config_home = tempfile::tempdir().unwrap(); + let keyring_dir = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &[ + "config", + "set-keyring", + &keyring_dir.path().to_string_lossy(), + ], + ); + assert_success(&out, "config set-keyring"); + + let config_dir = config_home.path().join("gitveil"); + let mode = fs::metadata(&config_dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "config dir should be 0o700, got 0o{:o}", mode); +} + +#[test] +fn test_config_set_keyring_overwrites() { + let config_home = tempfile::tempdir().unwrap(); + let keyring_dir1 = tempfile::tempdir().unwrap(); + let keyring_dir2 = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + // Set first + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &[ + "config", + "set-keyring", + &keyring_dir1.path().to_string_lossy(), + ], + ); + assert_success(&out, "config set-keyring first"); + + // Set second (overwrite) + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &[ + "config", + "set-keyring", + &keyring_dir2.path().to_string_lossy(), + ], + ); + assert_success(&out, "config set-keyring second"); + + // Show should have second path + let out = gitveil_with_config_home(config_home.path(), work_dir.path(), &["config", "show"]); + assert_success(&out, "config show after overwrite"); + let stdout = String::from_utf8_lossy(&out.stdout); + let expected = fs::canonicalize(keyring_dir2.path()).unwrap(); + assert!( + stdout.contains(&expected.to_string_lossy().to_string()), + "should show second path.\nExpected: {}\nGot: {}", + expected.display(), + stdout + ); +} + +#[test] +fn test_config_set_keyring_path_canonicalized() { + let config_home = tempfile::tempdir().unwrap(); + let keyring_dir = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + // Use a path with .. component + let subdir = keyring_dir.path().join("sub"); + fs::create_dir(&subdir).unwrap(); + let dotdot_path = subdir.join(".."); + + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &["config", "set-keyring", &dotdot_path.to_string_lossy()], + ); + assert_success(&out, "config set-keyring with .."); + + // Read raw config to verify it's canonicalized (no ..) + let config_file = config_home.path().join("gitveil").join("config"); + let content = fs::read_to_string(&config_file).unwrap(); + assert!( + !content.contains(".."), + "stored path should be canonicalized (no '..'): {}", + content + ); +} + +#[cfg(unix)] +#[test] +fn test_config_set_keyring_symlink_resolved() { + let config_home = tempfile::tempdir().unwrap(); + let real_dir = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + // Create a symlink to the real directory + let symlink_path = work_dir.path().join("keyring-link"); + std::os::unix::fs::symlink(real_dir.path(), &symlink_path).unwrap(); + + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &["config", "set-keyring", &symlink_path.to_string_lossy()], + ); + assert_success(&out, "config set-keyring symlink"); + + // Read raw config — should contain the real path, not the symlink + let config_file = config_home.path().join("gitveil").join("config"); + let content = fs::read_to_string(&config_file).unwrap(); + let expected = fs::canonicalize(real_dir.path()).unwrap(); + assert_eq!( + content.trim(), + expected.to_string_lossy().as_ref(), + "stored path should be the real path, not the symlink" + ); +} + +#[cfg(unix)] +#[test] +fn test_config_set_keyring_rejects_symlink_to_file() { + let config_home = tempfile::tempdir().unwrap(); + let work_dir = tempfile::tempdir().unwrap(); + + // Create a regular file + let file = work_dir.path().join("not-a-dir.txt"); + fs::write(&file, "hello").unwrap(); + + // Create symlink to that file + let symlink_path = work_dir.path().join("link-to-file"); + std::os::unix::fs::symlink(&file, &symlink_path).unwrap(); + + let out = gitveil_with_config_home( + config_home.path(), + work_dir.path(), + &["config", "set-keyring", &symlink_path.to_string_lossy()], + ); + assert!(!out.status.success(), "should reject symlink to file"); +} + +// ─── add-gpg-user Keyring Fallback Tests ─────────────────────── + +#[test] +fn test_add_gpg_user_no_args_no_keyring_shows_error() { + let dir = make_test_repo(); + let config_home = tempfile::tempdir().unwrap(); + + // Init so the repo has keys + assert_success( + &gitveil_with_config_home(config_home.path(), dir.path(), &["init"]), + "gitveil init", + ); + + // No args, no keyring configured + let out = gitveil_with_config_home(config_home.path(), dir.path(), &["add-gpg-user"]); + assert!( + !out.status.success(), + "should fail with no args and no keyring" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("GPG user ID is required") || stderr.contains("set-keyring"), + "should mention GPG user ID or keyring setup: {}", + stderr + ); +} + +#[test] +fn test_add_gpg_user_no_args_keyring_configured_but_empty_dir_errors() { + let dir = make_test_repo(); + let config_home = tempfile::tempdir().unwrap(); + let empty_keyring = tempfile::tempdir().unwrap(); + + // Init + assert_success( + &gitveil_with_config_home(config_home.path(), dir.path(), &["init"]), + "gitveil init", + ); + + // Configure keyring to empty dir + assert_success( + &gitveil_with_config_home( + config_home.path(), + dir.path(), + &[ + "config", + "set-keyring", + &empty_keyring.path().to_string_lossy(), + ], + ), + "config set-keyring", + ); + + // add-gpg-user with no args should try keyring, find nothing + let out = gitveil_with_config_home(config_home.path(), dir.path(), &["add-gpg-user"]); + assert!( + !out.status.success(), + "should fail when keyring dir is empty" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no GPG public key files found"), + "should say no keys found: {}", + stderr + ); +} + +#[test] +fn test_add_gpg_user_no_args_keyring_dir_gone_errors() { + let dir = make_test_repo(); + let config_home = tempfile::tempdir().unwrap(); + let keyring = tempfile::tempdir().unwrap(); + let keyring_path = keyring.path().to_path_buf(); + + // Init + assert_success( + &gitveil_with_config_home(config_home.path(), dir.path(), &["init"]), + "gitveil init", + ); + + // Configure keyring + assert_success( + &gitveil_with_config_home( + config_home.path(), + dir.path(), + &["config", "set-keyring", &keyring_path.to_string_lossy()], + ), + "config set-keyring", + ); + + // Delete the keyring directory + drop(keyring); + assert!(!keyring_path.exists(), "keyring dir should be deleted"); + + // add-gpg-user should report the dir is gone + let out = gitveil_with_config_home(config_home.path(), dir.path(), &["add-gpg-user"]); + assert!( + !out.status.success(), + "should fail when keyring dir is deleted" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no longer exists"), + "should say keyring path no longer exists: {}", + stderr + ); +} + +#[test] +fn test_add_gpg_user_from_still_takes_precedence() { + let dir = make_test_repo(); + let config_home = tempfile::tempdir().unwrap(); + let keyring = tempfile::tempdir().unwrap(); + let from_dir = tempfile::tempdir().unwrap(); + + // Init + assert_success( + &gitveil_with_config_home(config_home.path(), dir.path(), &["init"]), + "gitveil init", + ); + + // Configure keyring + assert_success( + &gitveil_with_config_home( + config_home.path(), + dir.path(), + &["config", "set-keyring", &keyring.path().to_string_lossy()], + ), + "config set-keyring", + ); + + // --from with an empty dir should use --from, not keyring + let out = gitveil_with_config_home( + config_home.path(), + dir.path(), + &["add-gpg-user", "--from", &from_dir.path().to_string_lossy()], + ); + assert!(!out.status.success(), "should fail (empty --from dir)"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no GPG public key files found"), + "--from should take precedence over keyring: {}", + stderr + ); +} + +#[test] +fn test_add_gpg_user_userid_still_takes_precedence() { + let dir = make_test_repo(); + let config_home = tempfile::tempdir().unwrap(); + let keyring = tempfile::tempdir().unwrap(); + + // Init + assert_success( + &gitveil_with_config_home(config_home.path(), dir.path(), &["init"]), + "gitveil init", + ); + + // Configure keyring + assert_success( + &gitveil_with_config_home( + config_home.path(), + dir.path(), + &["config", "set-keyring", &keyring.path().to_string_lossy()], + ), + "config set-keyring", + ); + + // Provide a bogus user ID — should attempt GPG lookup, not keyring scan + let out = gitveil_with_config_home( + config_home.path(), + dir.path(), + &["add-gpg-user", "nonexistent-user@test.invalid"], + ); + assert!( + !out.status.success(), + "should fail (user not in GPG keyring)" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + // Should be a GPG error, not a keyring/scan error + assert!( + stderr.to_lowercase().contains("gpg"), + "error should be from GPG lookup, not keyring scan: {}", + stderr + ); +} + +// ─── Scan Security Tests ─────────────────────────────────────── + +#[test] +fn test_scan_skips_non_key_extensions() { + let dir = make_test_repo(); + let config_home = tempfile::tempdir().unwrap(); + let keyring = tempfile::tempdir().unwrap(); + + // Create files with non-key extensions + fs::write(keyring.path().join("readme.txt"), "not a key").unwrap(); + fs::write(keyring.path().join("notes.md"), "not a key").unwrap(); + fs::write(keyring.path().join("data.json"), "not a key").unwrap(); + + // Init + assert_success( + &gitveil_with_config_home(config_home.path(), dir.path(), &["init"]), + "gitveil init", + ); + + // Configure keyring + assert_success( + &gitveil_with_config_home( + config_home.path(), + dir.path(), + &["config", "set-keyring", &keyring.path().to_string_lossy()], + ), + "config set-keyring", + ); + + // Should report no keys found (non-key extensions ignored) + let out = gitveil_with_config_home(config_home.path(), dir.path(), &["add-gpg-user"]); + assert!(!out.status.success(), "should fail with only non-key files"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no GPG public key files found"), + "should report no keys found: {}", + stderr + ); +} + +#[test] +fn test_scan_empty_directory() { + let dir = make_test_repo(); + let config_home = tempfile::tempdir().unwrap(); + let keyring = tempfile::tempdir().unwrap(); + + // Init + assert_success( + &gitveil_with_config_home(config_home.path(), dir.path(), &["init"]), + "gitveil init", + ); + + // Configure empty keyring + assert_success( + &gitveil_with_config_home( + config_home.path(), + dir.path(), + &["config", "set-keyring", &keyring.path().to_string_lossy()], + ), + "config set-keyring", + ); + + let out = gitveil_with_config_home(config_home.path(), dir.path(), &["add-gpg-user"]); + assert!(!out.status.success(), "should fail with empty keyring dir"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no GPG public key files found"), + "should say no keys: {}", + stderr + ); +} + +#[cfg(unix)] +#[test] +fn test_scan_skips_symlinks() { + let dir = make_test_repo(); + let config_home = tempfile::tempdir().unwrap(); + let keyring = tempfile::tempdir().unwrap(); + + // Create a symlinked .asc file (should be skipped) + let target = tempfile::tempdir().unwrap(); + let target_file = target.path().join("target.asc"); + fs::write(&target_file, "fake key content").unwrap(); + let symlink = keyring.path().join("linked.asc"); + std::os::unix::fs::symlink(&target_file, &symlink).unwrap(); + + // Init + assert_success( + &gitveil_with_config_home(config_home.path(), dir.path(), &["init"]), + "gitveil init", + ); + + // Configure keyring + assert_success( + &gitveil_with_config_home( + config_home.path(), + dir.path(), + &["config", "set-keyring", &keyring.path().to_string_lossy()], + ), + "config set-keyring", + ); + + // Should skip the symlinked file and find no valid keys + let out = gitveil_with_config_home(config_home.path(), dir.path(), &["add-gpg-user"]); + assert!( + !out.status.success(), + "should fail (symlinked files skipped)" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no GPG public key files found"), + "should find no keys (symlink skipped): {}", + stderr + ); +} From bcd7bbb6e12077a0620f93cba77fa3eec6b54018 Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 10:55:29 +0200 Subject: [PATCH 06/19] docs: add config commands and keyring fallback documentation --- CONTRIBUTING.md | 6 +++++- README.md | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5231414..ee672a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ cargo build cargo test ``` -All 74 tests should pass (28 unit + 40 integration + 6 cross-compatibility). They cover: +All 77 tests should pass (31 unit + 40 integration + 6 cross-compatibility). They cover: - AES-256-CTR encryption/decryption round-trips - HMAC-SHA1 known-answer vectors - Key file TLV serialization/deserialization @@ -36,6 +36,10 @@ All 74 tests should pass (28 unit + 40 integration + 6 cross-compatibility). The - Status, export-key, quiet mode, error messages (integration) - Edge cases: empty files, binary files, multi-key lock (integration) - Pipe deadlock regression: many-file and large-blob status, unlock, lock (integration) +- Global config: XDG resolution, keyring path save/load/remove, permissions (unit) +- Config CLI: set-keyring, unset-keyring, show, overwrite, canonicalization, symlinks (integration) +- Keyring fallback: add-gpg-user with no args, empty dir, deleted dir, precedence (integration) +- Scan security: symlink skipping, non-key extensions, empty directory (integration) - Cross-tool: key exchange, encrypt/decrypt, named keys, binary files (cross-compatibility) The cross-compatibility tests (`tests/cross_compat.rs`) verify interoperability with diff --git a/README.md b/README.md index b06e41c..356f186 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ gitveil add-gpg-user [-k ] [-n] [--trusted] [--from ] [` | Import GPG key(s) from a file, directory, or git URL | +When called with no arguments and no `--from`, gitveil checks for a globally configured keyring directory (see `gitveil config set-keyring`). If configured, it scans the directory and shows an interactive picker. + #### Import keys from a shared keyring If your team stores GPG public keys in a shared repository, you can import them directly: @@ -203,6 +205,25 @@ gitveil add-gpg-user --from git@github.com:company/gpg-keys.git When pointing at a directory (or git URL), gitveil scans for `.asc`, `.gpg`, `.pub`, and `.key` files, shows a list of found keys (name, email, fingerprint), and lets you select one or more to add as collaborators. +### `gitveil config` + +Manage global gitveil configuration. + +```bash +# Set a global GPG keyring directory +gitveil config set-keyring /path/to/team-keys + +# Show current configuration +gitveil config show + +# Remove the keyring setting +gitveil config unset-keyring +``` + +When a keyring directory is configured, `gitveil add-gpg-user` (with no arguments and no `--from`) will automatically scan the keyring directory and present an interactive picker to select GPG keys. This is useful when your team stores GPG public keys in a shared folder or git repository. + +The keyring path is stored in `~/.config/gitveil/config` (respects `$XDG_CONFIG_HOME`). The config file is created with 0600 permissions and the config directory with 0700 permissions. + ### `gitveil rm-gpg-user` Remove a GPG user's access. @@ -309,6 +330,7 @@ The clean filter must read the entire file into memory to compute the HMAC-SHA1 src/ main.rs # Entry point + CLI dispatch cli.rs # clap CLI definitions + config.rs # Global configuration (XDG keyring path) constants.rs # Magic bytes, sizes, field IDs error.rs # Error types crypto/ @@ -330,6 +352,7 @@ src/ status.rs # Show encryption status export_key.rs # Export symmetric key add_gpg_user.rs # Add GPG collaborator + config.rs # Global config management rm_gpg_user.rs # Remove GPG collaborator ls_gpg_users.rs # List GPG collaborators git/ From 3e8dbd0c5a42e22b287f37b312051290da9f88c3 Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 10:59:07 +0200 Subject: [PATCH 07/19] fix: address code review findings in config module - Re-canonicalize path on load for defense-in-depth - Add trailing newline to config file - Use RAII guard for env var mutation in unit tests (safety) --- src/config.rs | 45 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/config.rs b/src/config.rs index a158e50..e6b4895 100644 --- a/src/config.rs +++ b/src/config.rs @@ -85,7 +85,10 @@ pub fn load_keyring_path() -> Result, GitVeilError> { ))); } - Ok(Some(path)) + // Re-canonicalize for defense-in-depth (config file could be hand-edited) + let canonical = fs::canonicalize(&path).unwrap_or(path); + + Ok(Some(canonical)) } /// Save a keyring path to the config file. @@ -120,7 +123,7 @@ pub fn save_keyring_path(path: &Path) -> Result<(), GitVeilError> { create_config_dir(&dir)?; let cf = dir.join("config"); - write_config_file(&cf, canonical.to_string_lossy().as_ref())?; + write_config_file(&cf, &format!("{}\n", canonical.to_string_lossy()))?; Ok(()) } @@ -179,20 +182,47 @@ mod tests { use super::*; use tempfile::TempDir; + // SAFETY: These tests mutate the process environment (XDG_CONFIG_HOME). + // They must be run with --test-threads=1 to avoid races: + // cargo test --bin gitveil config::tests -- --test-threads=1 + + /// Helper to set XDG_CONFIG_HOME for a test scope, restoring it on drop. + struct XdgGuard { + old: Option, + } + + impl XdgGuard { + fn set(path: &Path) -> Self { + let old = std::env::var("XDG_CONFIG_HOME").ok(); + // SAFETY: tests run single-threaded via --test-threads=1 + unsafe { std::env::set_var("XDG_CONFIG_HOME", path) }; + Self { old } + } + } + + impl Drop for XdgGuard { + fn drop(&mut self) { + // SAFETY: tests run single-threaded via --test-threads=1 + match &self.old { + Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) }, + None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") }, + } + } + } + #[test] fn test_config_dir_uses_xdg() { let tmp = TempDir::new().unwrap(); - std::env::set_var("XDG_CONFIG_HOME", tmp.path()); + let _guard = XdgGuard::set(tmp.path()); let dir = config_dir().unwrap(); assert_eq!(dir, tmp.path().join("gitveil")); - std::env::remove_var("XDG_CONFIG_HOME"); } #[test] fn test_save_load_roundtrip() { let tmp_config = TempDir::new().unwrap(); let tmp_keyring = TempDir::new().unwrap(); - std::env::set_var("XDG_CONFIG_HOME", tmp_config.path()); + let _guard = XdgGuard::set(tmp_config.path()); save_keyring_path(tmp_keyring.path()).unwrap(); let loaded = load_keyring_path().unwrap(); @@ -201,16 +231,13 @@ mod tests { // Canonicalize both for comparison (macOS /private/var vs /var) let expected = fs::canonicalize(tmp_keyring.path()).unwrap(); assert_eq!(loaded_path, expected); - - std::env::remove_var("XDG_CONFIG_HOME"); } #[test] fn test_load_missing_config_returns_none() { let tmp = TempDir::new().unwrap(); - std::env::set_var("XDG_CONFIG_HOME", tmp.path()); + let _guard = XdgGuard::set(tmp.path()); let loaded = load_keyring_path().unwrap(); assert!(loaded.is_none()); - std::env::remove_var("XDG_CONFIG_HOME"); } } From 6c208d700ed44a4bc38c3dd43c092faa7e115b7b Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 11:08:27 +0200 Subject: [PATCH 08/19] docs: update CONTRIBUTING project layout with config module --- CONTRIBUTING.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee672a5..fb30071 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,10 +64,11 @@ src/ key/ Key file format (TLV serialization, entries, key container) filter/ Git clean/smudge/diff filters commands/ User-facing commands (init, lock, unlock, status, export-key, - add/rm/ls-gpg-users) + add/rm/ls-gpg-users, config) git/ Git repository helpers (config, checkout, repo inspection) gpg/ GPG integration (key import, encrypt/decrypt via gpg CLI) cli.rs clap CLI definitions + shell completion generation + config.rs Global configuration (XDG keyring path) constants.rs Shared constants (magic bytes, sizes, field IDs) error.rs Error types main.rs Entry point From 0651efcee59fc049d734a693f68a6299c00156a5 Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 11:16:01 +0200 Subject: [PATCH 09/19] docs: document keyring directory format and structure --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 356f186..96c3a53 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,18 @@ gitveil config unset-keyring When a keyring directory is configured, `gitveil add-gpg-user` (with no arguments and no `--from`) will automatically scan the keyring directory and present an interactive picker to select GPG keys. This is useful when your team stores GPG public keys in a shared folder or git repository. +The keyring directory has no special format -- it's just a folder containing GPG public key files (exported with `gpg --export` or `gpg --armor --export`). Files are matched by extension (`.asc`, `.gpg`, `.pub`, `.key`) and can be organized in subdirectories. Non-key files and symlinks are ignored. + +``` +team-keys/ +├── engineering/ +│ ├── alice.asc +│ └── bob.pub +├── design/ +│ └── carol.gpg +└── README.md # ignored (not a key extension) +``` + The keyring path is stored in `~/.config/gitveil/config` (respects `$XDG_CONFIG_HOME`). The config file is created with 0600 permissions and the config directory with 0700 permissions. ### `gitveil rm-gpg-user` From ae86c9ecf43b0fd372af6ff7e97f4615e01ea9bc Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 11:40:10 +0200 Subject: [PATCH 10/19] fix: skip GPG-dependent test on Windows CI (prevents hang) --- tests/integration.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration.rs b/tests/integration.rs index e57f748..f5a3a92 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1230,6 +1230,8 @@ fn test_add_gpg_user_from_still_takes_precedence() { ); } +// This test invokes GPG which is not installed on Windows CI and can hang. +#[cfg(not(target_os = "windows"))] #[test] fn test_add_gpg_user_userid_still_takes_precedence() { let dir = make_test_repo(); From 08c4e04bee5c4503bae0d987d81e9e807ee881d4 Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 11:47:31 +0200 Subject: [PATCH 11/19] fix: resolve test races and Windows GPG hang - Unit tests: add Mutex to serialize env var mutations (fixes macOS CI race) - GPG test: set GNUPGHOME to empty temp dir so GPG fails fast on all platforms instead of hanging (restores test on Windows) --- src/config.rs | 17 +++++++++++------ tests/integration.rs | 19 +++++++++++-------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/config.rs b/src/config.rs index e6b4895..65fc992 100644 --- a/src/config.rs +++ b/src/config.rs @@ -180,13 +180,14 @@ fn write_config_file(path: &Path, content: &str) -> Result<(), GitVeilError> { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; use tempfile::TempDir; - // SAFETY: These tests mutate the process environment (XDG_CONFIG_HOME). - // They must be run with --test-threads=1 to avoid races: - // cargo test --bin gitveil config::tests -- --test-threads=1 + // Mutex serializes all tests that mutate XDG_CONFIG_HOME. + // Without this, parallel test threads race on the shared env var. + static ENV_LOCK: Mutex<()> = Mutex::new(()); - /// Helper to set XDG_CONFIG_HOME for a test scope, restoring it on drop. + /// Set XDG_CONFIG_HOME for a test scope, restoring it on drop. struct XdgGuard { old: Option, } @@ -194,7 +195,8 @@ mod tests { impl XdgGuard { fn set(path: &Path) -> Self { let old = std::env::var("XDG_CONFIG_HOME").ok(); - // SAFETY: tests run single-threaded via --test-threads=1 + // SAFETY: caller holds ENV_LOCK so no other test thread is + // reading or writing this env var concurrently. unsafe { std::env::set_var("XDG_CONFIG_HOME", path) }; Self { old } } @@ -202,7 +204,7 @@ mod tests { impl Drop for XdgGuard { fn drop(&mut self) { - // SAFETY: tests run single-threaded via --test-threads=1 + // SAFETY: caller still holds ENV_LOCK (guard dropped before lock) match &self.old { Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) }, None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") }, @@ -212,6 +214,7 @@ mod tests { #[test] fn test_config_dir_uses_xdg() { + let _lock = ENV_LOCK.lock().unwrap(); let tmp = TempDir::new().unwrap(); let _guard = XdgGuard::set(tmp.path()); let dir = config_dir().unwrap(); @@ -220,6 +223,7 @@ mod tests { #[test] fn test_save_load_roundtrip() { + let _lock = ENV_LOCK.lock().unwrap(); let tmp_config = TempDir::new().unwrap(); let tmp_keyring = TempDir::new().unwrap(); let _guard = XdgGuard::set(tmp_config.path()); @@ -235,6 +239,7 @@ mod tests { #[test] fn test_load_missing_config_returns_none() { + let _lock = ENV_LOCK.lock().unwrap(); let tmp = TempDir::new().unwrap(); let _guard = XdgGuard::set(tmp.path()); let loaded = load_keyring_path().unwrap(); diff --git a/tests/integration.rs b/tests/integration.rs index f5a3a92..e5a1fcf 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1230,13 +1230,12 @@ fn test_add_gpg_user_from_still_takes_precedence() { ); } -// This test invokes GPG which is not installed on Windows CI and can hang. -#[cfg(not(target_os = "windows"))] #[test] fn test_add_gpg_user_userid_still_takes_precedence() { let dir = make_test_repo(); let config_home = tempfile::tempdir().unwrap(); let keyring = tempfile::tempdir().unwrap(); + let gpg_home = tempfile::tempdir().unwrap(); // Init assert_success( @@ -1254,12 +1253,16 @@ fn test_add_gpg_user_userid_still_takes_precedence() { "config set-keyring", ); - // Provide a bogus user ID — should attempt GPG lookup, not keyring scan - let out = gitveil_with_config_home( - config_home.path(), - dir.path(), - &["add-gpg-user", "nonexistent-user@test.invalid"], - ); + // Provide a bogus user ID — should attempt GPG lookup, not keyring scan. + // Set GNUPGHOME to an empty temp dir so GPG fails fast on all platforms + // (prevents hangs on Windows where GPG may try to initialize a default keyring). + let out = Command::new(gitveil_bin()) + .args(["add-gpg-user", "nonexistent-user@test.invalid"]) + .current_dir(dir.path()) + .env("XDG_CONFIG_HOME", config_home.path()) + .env("GNUPGHOME", gpg_home.path()) + .output() + .unwrap(); assert!( !out.status.success(), "should fail (user not in GPG keyring)" From 425726672bb4688fa4b7ea0ba49728b148bed10a Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 13:18:26 +0200 Subject: [PATCH 12/19] test: add 14 GPG integration tests and enforce testing policy - New tests/gpg_integration.rs: real GPG operations with temp keyrings - add-gpg-user: by email, fingerprint, --trusted, --no-commit, -k, --from - rm-gpg-user: remove, --no-commit, user not found error - ls-gpg-users: list, no users, named key - GPG unlock roundtrip: add user -> lock -> unlock via GPG - Multi-user: add 2, remove 1, verify count - All tests auto-skip if GPG unavailable (skip_without_gpg! macro) - CI: install GPG on Linux/macOS, run gpg_integration test step - CLAUDE.md: add mandatory testing policy for all commands/features - Total: 91 tests (31 unit + 40 integration + 14 GPG + 6 cross-compat) --- .github/workflows/ci.yml | 11 +- CLAUDE.md | 13 + CONTRIBUTING.md | 12 +- tests/gpg_integration.rs | 704 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 733 insertions(+), 7 deletions(-) create mode 100644 tests/gpg_integration.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01ddc34..bb11475 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,13 +34,13 @@ jobs: target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - name: Install git-crypt (Linux) + - name: Install git-crypt and GPG (Linux) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y git-crypt + run: sudo apt-get update && sudo apt-get install -y git-crypt gnupg - - name: Install git-crypt (macOS) + - name: Install git-crypt and GPG (macOS) if: runner.os == 'macOS' - run: brew install git-crypt + run: brew install git-crypt gnupg - name: Check formatting run: cargo fmt --check @@ -54,5 +54,8 @@ jobs: - name: Integration tests run: cargo test --test integration + - name: GPG integration tests + run: cargo test --test gpg_integration + - name: Cross-compatibility tests run: cargo test --test cross_compat diff --git a/CLAUDE.md b/CLAUDE.md index b58f56f..c140e4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,19 @@ When adding features, changing CLI flags, modifying commands, or altering behavi - Update CONTRIBUTING.md if project structure, test count, build steps, or development guidelines changed - Keep the test count in CONTRIBUTING.md accurate after adding/removing tests +## Testing Requirements + +Every command, subcommand, and code path MUST have integration tests. This is non-negotiable for security-critical software. + +- **Every CLI command** must have integration tests covering happy path AND error paths +- **Every flag/option** on every command must be tested +- **GPG-dependent commands** (add-gpg-user, rm-gpg-user, ls-gpg-users, unlock via GPG) must have tests that exercise real GPG operations using test keys in a temp GNUPGHOME +- **Tests must run on all 3 CI platforms** (Linux, macOS, Windows). Only use `#[cfg(unix)]` for genuinely Unix-only concepts (file mode bits, Unix symlinks) +- **When adding a new command or feature**: write tests BEFORE or WITH the implementation, never after. No PR should add a command without corresponding tests. +- **When modifying an existing command**: verify existing tests still cover the behavior, add new tests if the change adds flags or code paths +- **GPG tests** should auto-skip gracefully if GPG is not available (use a `skip_without_gpg!()` macro, same pattern as `skip_without_git_crypt!()` in cross_compat.rs) +- Keep the test count in CONTRIBUTING.md accurate after adding/removing tests + ## Code Quality - Run `cargo clippy` and fix warnings before committing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb30071..48471bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ cargo build cargo test ``` -All 77 tests should pass (31 unit + 40 integration + 6 cross-compatibility). They cover: +All 91 tests should pass (31 unit + 40 integration + 14 GPG integration + 6 cross-compatibility). They cover: - AES-256-CTR encryption/decryption round-trips - HMAC-SHA1 known-answer vectors - Key file TLV serialization/deserialization @@ -40,6 +40,11 @@ All 77 tests should pass (31 unit + 40 integration + 6 cross-compatibility). The - Config CLI: set-keyring, unset-keyring, show, overwrite, canonicalization, symlinks (integration) - Keyring fallback: add-gpg-user with no args, empty dir, deleted dir, precedence (integration) - Scan security: symlink skipping, non-key extensions, empty directory (integration) +- GPG add-gpg-user: by email, fingerprint, --trusted, --no-commit, -k, --from file (GPG integration) +- GPG rm-gpg-user: remove, --no-commit, user not found (GPG integration) +- GPG ls-gpg-users: list, no users, named key (GPG integration) +- GPG unlock roundtrip: add user, lock, unlock via GPG (GPG integration) +- GPG multi-user: add 2 users, remove 1, verify count (GPG integration) - Cross-tool: key exchange, encrypt/decrypt, named keys, binary files (cross-compatibility) The cross-compatibility tests (`tests/cross_compat.rs`) verify interoperability with @@ -73,8 +78,9 @@ src/ error.rs Error types main.rs Entry point tests/ - integration.rs E2E tests using temporary git repos - cross_compat.rs Cross-tool tests against git-crypt + integration.rs E2E tests using temporary git repos + gpg_integration.rs GPG user management tests (add/rm/ls, unlock via GPG) + cross_compat.rs Cross-tool tests against git-crypt benchmark/ bench.sh Status command scaling by file count bench_large_files.sh Status with large binary files (Unity-like repos) diff --git a/tests/gpg_integration.rs b/tests/gpg_integration.rs new file mode 100644 index 0000000..35ae23d --- /dev/null +++ b/tests/gpg_integration.rs @@ -0,0 +1,704 @@ +//! GPG integration tests for gitveil. +//! +//! These tests exercise real GPG operations: key generation, encryption, +//! decryption, and user management. Each test creates a temporary GPG +//! home directory with test keys, fully isolated from the system keyring. +//! +//! Tests skip automatically at runtime when GPG is not available. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +// ─── Helpers ─────────────────────────────────────────────────── + +/// Check whether gpg is available and functional on this system. +fn gpg_available() -> bool { + Command::new("gpg") + .args(["--version"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Early-return from a test when gpg is not installed. +macro_rules! skip_without_gpg { + () => { + if !gpg_available() { + eprintln!("SKIPPED: gpg not found in PATH"); + return; + } + }; +} + +/// Get the path to the compiled gitveil binary. +fn gitveil_bin() -> PathBuf { + let mut path = std::env::current_exe() + .expect("cannot get test exe path") + .parent() + .expect("cannot get parent") + .parent() + .expect("cannot get grandparent") + .to_path_buf(); + path.push("gitveil"); + path +} + +/// Run gitveil with a custom GNUPGHOME for isolated GPG testing. +fn gitveil_gpg(gpg_home: &Path, dir: &Path, args: &[&str]) -> Output { + Command::new(gitveil_bin()) + .args(args) + .current_dir(dir) + .env("GNUPGHOME", gpg_home) + .output() + .unwrap_or_else(|e| panic!("failed to run gitveil {:?}: {}", args, e)) +} + +/// Run git in a directory. +fn git(dir: &Path, args: &[&str]) -> Output { + Command::new("git") + .args(args) + .current_dir(dir) + .output() + .unwrap_or_else(|e| panic!("failed to run git {:?}: {}", args, e)) +} + +/// Run gpg with a custom GNUPGHOME. +fn gpg(gpg_home: &Path, args: &[&str]) -> Output { + Command::new("gpg") + .args(args) + .env("GNUPGHOME", gpg_home) + .output() + .unwrap_or_else(|e| panic!("failed to run gpg {:?}: {}", args, e)) +} + +/// Assert a command succeeded, printing stderr on failure. +fn assert_success(output: &Output, context: &str) { + assert!( + output.status.success(), + "{} failed (exit {:?}):\nstdout: {}\nstderr: {}", + context, + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +/// Create a temp directory with an initialized git repo + gitveil init. +fn make_initialized_repo(gpg_home: &Path) -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("cannot create tempdir"); + assert_success(&git(dir.path(), &["init"]), "git init"); + assert_success( + &git(dir.path(), &["config", "user.email", "test@gitveil.test"]), + "git config email", + ); + assert_success( + &git(dir.path(), &["config", "user.name", "Test"]), + "git config name", + ); + // Initial commit so HEAD exists + let readme = dir.path().join("README"); + fs::write(&readme, "test repo\n").unwrap(); + assert_success(&git(dir.path(), &["add", "README"]), "git add README"); + assert_success( + &git(dir.path(), &["commit", "-m", "initial"]), + "git commit initial", + ); + // Initialize gitveil + assert_success( + &gitveil_gpg(gpg_home, dir.path(), &["init"]), + "gitveil init", + ); + dir +} + +/// Generate a GPG test key in the given GNUPGHOME. Returns the fingerprint. +fn generate_test_key(gpg_home: &Path, name: &str, email: &str) -> String { + let key_spec = format!( + "%no-protection\nKey-Type: RSA\nKey-Length: 2048\nName-Real: {}\nName-Email: {}\nExpire-Date: 0\n%commit\n", + name, email + ); + + let mut child = Command::new("gpg") + .args(["--batch", "--gen-key"]) + .env("GNUPGHOME", gpg_home) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn gpg"); + + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(key_spec.as_bytes()) + .expect("failed to write key spec"); + + let output = child.wait_with_output().expect("gpg gen-key failed"); + assert!( + output.status.success(), + "gpg key generation failed:\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Get the fingerprint + let list_output = gpg( + gpg_home, + &["--with-colons", "--list-keys", "--fingerprint", email], + ); + assert_success(&list_output, "gpg list-keys"); + + let stdout = String::from_utf8_lossy(&list_output.stdout); + for line in stdout.lines() { + if line.starts_with("fpr:") { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() > 9 { + return parts[9].to_string(); + } + } + } + panic!("could not find fingerprint for generated key {}", email); +} + +/// Export a GPG public key to a file. +fn export_key_to_file(gpg_home: &Path, email: &str, output_path: &Path) { + let out = Command::new("gpg") + .args(["--armor", "--export", email]) + .env("GNUPGHOME", gpg_home) + .output() + .expect("failed to export key"); + assert_success(&out, "gpg export"); + fs::write(output_path, &out.stdout).expect("failed to write key file"); +} + +/// Count .gpg files in .git-crypt/keys//0/ +fn count_gpg_files(repo_dir: &Path, key_name: &str) -> usize { + let gpg_dir = repo_dir + .join(".git-crypt") + .join("keys") + .join(key_name) + .join("0"); + if !gpg_dir.is_dir() { + return 0; + } + fs::read_dir(&gpg_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .map(|ext| ext == "gpg") + .unwrap_or(false) + }) + .count() +} + +/// Get the number of commits in the repo. +fn commit_count(dir: &Path) -> usize { + let out = git(dir, &["rev-list", "--count", "HEAD"]); + assert_success(&out, "git rev-list --count"); + String::from_utf8_lossy(&out.stdout).trim().parse().unwrap() +} + +// ─── add-gpg-user Tests ──────────────────────────────────────── + +#[test] +fn test_add_gpg_user_by_email() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + let fingerprint = generate_test_key(gpg_home.path(), "Alice Test", "alice@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + let out = gitveil_gpg( + gpg_home.path(), + dir.path(), + &["add-gpg-user", "--trusted", "alice@gitveil.test"], + ); + assert_success(&out, "add-gpg-user by email"); + + // Verify .gpg file was created + let gpg_file = dir + .path() + .join(".git-crypt") + .join("keys") + .join("default") + .join("0") + .join(format!("{}.gpg", fingerprint)); + assert!( + gpg_file.exists(), + "encrypted key file should exist at {}", + gpg_file.display() + ); + + // Verify it was committed + let log_out = git(dir.path(), &["log", "--oneline", "-1"]); + let log_msg = String::from_utf8_lossy(&log_out.stdout); + assert!( + log_msg.contains("gitveil collaborator"), + "commit message should mention collaborator: {}", + log_msg + ); +} + +#[test] +fn test_add_gpg_user_by_fingerprint() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + let fingerprint = generate_test_key(gpg_home.path(), "Bob Test", "bob@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + let out = gitveil_gpg( + gpg_home.path(), + dir.path(), + &["add-gpg-user", "--trusted", &fingerprint], + ); + assert_success(&out, "add-gpg-user by fingerprint"); + + assert_eq!(count_gpg_files(dir.path(), "default"), 1); +} + +#[test] +fn test_add_gpg_user_trusted_flag() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + generate_test_key(gpg_home.path(), "Carol Test", "carol@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + // Without --trusted, GPG may reject the key due to trust level. + // With --trusted, it should always work. + let out = gitveil_gpg( + gpg_home.path(), + dir.path(), + &["add-gpg-user", "--trusted", "carol@gitveil.test"], + ); + assert_success(&out, "add-gpg-user --trusted"); + assert_eq!(count_gpg_files(dir.path(), "default"), 1); +} + +#[test] +fn test_add_gpg_user_no_commit_flag() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + generate_test_key(gpg_home.path(), "Dave Test", "dave@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + let commits_before = commit_count(dir.path()); + + let out = gitveil_gpg( + gpg_home.path(), + dir.path(), + &[ + "add-gpg-user", + "--trusted", + "--no-commit", + "dave@gitveil.test", + ], + ); + assert_success(&out, "add-gpg-user --no-commit"); + + // .gpg file should exist + assert_eq!(count_gpg_files(dir.path(), "default"), 1); + + // But no new commit should have been created + let commits_after = commit_count(dir.path()); + assert_eq!( + commits_before, commits_after, + "--no-commit should not create a git commit" + ); +} + +#[test] +fn test_add_gpg_user_named_key() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + generate_test_key(gpg_home.path(), "Eve Test", "eve@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + // Init a named key + assert_success( + &gitveil_gpg(gpg_home.path(), dir.path(), &["init", "-k", "backend"]), + "gitveil init -k backend", + ); + + let out = gitveil_gpg( + gpg_home.path(), + dir.path(), + &[ + "add-gpg-user", + "--trusted", + "-k", + "backend", + "eve@gitveil.test", + ], + ); + assert_success(&out, "add-gpg-user -k backend"); + + // Should be under the named key, not default + assert_eq!(count_gpg_files(dir.path(), "backend"), 1); + assert_eq!(count_gpg_files(dir.path(), "default"), 0); +} + +#[test] +fn test_add_gpg_user_from_file() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + let fingerprint = generate_test_key(gpg_home.path(), "Frank Test", "frank@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + // Export the public key to a .asc file + let key_file = dir.path().join("frank.asc"); + export_key_to_file(gpg_home.path(), "frank@gitveil.test", &key_file); + assert!(key_file.exists(), "exported key file should exist"); + + let out = gitveil_gpg( + gpg_home.path(), + dir.path(), + &[ + "add-gpg-user", + "--trusted", + "--from", + &key_file.to_string_lossy(), + ], + ); + assert_success(&out, "add-gpg-user --from file"); + + // Verify .gpg file was created with the right fingerprint + let gpg_file = dir + .path() + .join(".git-crypt") + .join("keys") + .join("default") + .join("0") + .join(format!("{}.gpg", fingerprint)); + assert!(gpg_file.exists(), "encrypted key file should exist"); +} + +// ─── rm-gpg-user Tests ───────────────────────────────────────── + +#[test] +fn test_rm_gpg_user() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + generate_test_key(gpg_home.path(), "Grace Test", "grace@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + // Add user first + assert_success( + &gitveil_gpg( + gpg_home.path(), + dir.path(), + &["add-gpg-user", "--trusted", "grace@gitveil.test"], + ), + "add-gpg-user", + ); + assert_eq!(count_gpg_files(dir.path(), "default"), 1); + + // Remove user + let out = gitveil_gpg( + gpg_home.path(), + dir.path(), + &["rm-gpg-user", "grace@gitveil.test"], + ); + assert_success(&out, "rm-gpg-user"); + + // .gpg file should be gone + assert_eq!(count_gpg_files(dir.path(), "default"), 0); + + // Should have committed the removal + let log_out = git(dir.path(), &["log", "--oneline", "-1"]); + let log_msg = String::from_utf8_lossy(&log_out.stdout); + assert!( + log_msg.contains("Remove") || log_msg.contains("remove"), + "commit message should mention removal: {}", + log_msg + ); +} + +#[test] +fn test_rm_gpg_user_no_commit() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + generate_test_key(gpg_home.path(), "Heidi Test", "heidi@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + // Add user + assert_success( + &gitveil_gpg( + gpg_home.path(), + dir.path(), + &["add-gpg-user", "--trusted", "heidi@gitveil.test"], + ), + "add-gpg-user", + ); + + let commits_before = commit_count(dir.path()); + + // Remove with --no-commit + let out = gitveil_gpg( + gpg_home.path(), + dir.path(), + &["rm-gpg-user", "--no-commit", "heidi@gitveil.test"], + ); + assert_success(&out, "rm-gpg-user --no-commit"); + + // File should be gone + assert_eq!(count_gpg_files(dir.path(), "default"), 0); + + // No new commit + let commits_after = commit_count(dir.path()); + assert_eq!( + commits_before, commits_after, + "--no-commit should not create a git commit" + ); +} + +#[test] +fn test_rm_gpg_user_not_found() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + generate_test_key(gpg_home.path(), "Ivan Test", "ivan@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + // Add a user first (so .git-crypt/keys exists) + assert_success( + &gitveil_gpg( + gpg_home.path(), + dir.path(), + &["add-gpg-user", "--trusted", "ivan@gitveil.test"], + ), + "add-gpg-user", + ); + + // Try to remove a different (nonexistent) user — need a key that exists in GPG + // but was never added as a gitveil collaborator + generate_test_key(gpg_home.path(), "Nobody Test", "nobody@gitveil.test"); + let out = gitveil_gpg( + gpg_home.path(), + dir.path(), + &["rm-gpg-user", "nobody@gitveil.test"], + ); + assert!( + !out.status.success(), + "should fail when user not found as collaborator" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("No encrypted key found") || stderr.contains("not found"), + "error should say user not found: {}", + stderr + ); +} + +// ─── ls-gpg-users Tests ──────────────────────────────────────── + +#[test] +fn test_ls_gpg_users() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + let fingerprint = generate_test_key(gpg_home.path(), "Judy Test", "judy@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + // Add user + assert_success( + &gitveil_gpg( + gpg_home.path(), + dir.path(), + &["add-gpg-user", "--trusted", "judy@gitveil.test"], + ), + "add-gpg-user", + ); + + // List users + let out = gitveil_gpg(gpg_home.path(), dir.path(), &["ls-gpg-users"]); + assert_success(&out, "ls-gpg-users"); + + let stdout = String::from_utf8_lossy(&out.stdout); + // Should show the fingerprint (at least partial) + let fp_short = &fingerprint[..16]; + assert!( + stdout.contains(fp_short) || stdout.contains("Judy Test"), + "ls-gpg-users should show the user's fingerprint or name.\nExpected '{}' or 'Judy Test' in:\n{}", + fp_short, + stdout + ); +} + +#[test] +fn test_ls_gpg_users_no_users() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + let dir = make_initialized_repo(gpg_home.path()); + + // List users when none configured + let out = gitveil_gpg(gpg_home.path(), dir.path(), &["ls-gpg-users"]); + // This should either succeed with "no GPG users" or fail gracefully + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + assert!( + combined.contains("no GPG users") || combined.contains("No GPG users"), + "should indicate no users configured: {}", + combined + ); +} + +#[test] +fn test_ls_gpg_users_named_key() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + generate_test_key(gpg_home.path(), "Karl Test", "karl@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + // Init named key and add user to it + assert_success( + &gitveil_gpg(gpg_home.path(), dir.path(), &["init", "-k", "backend"]), + "gitveil init -k backend", + ); + assert_success( + &gitveil_gpg( + gpg_home.path(), + dir.path(), + &[ + "add-gpg-user", + "--trusted", + "-k", + "backend", + "karl@gitveil.test", + ], + ), + "add-gpg-user -k backend", + ); + + // List only the named key + let out = gitveil_gpg( + gpg_home.path(), + dir.path(), + &["ls-gpg-users", "-k", "backend"], + ); + assert_success(&out, "ls-gpg-users -k backend"); + + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("backend"), + "should show the backend key name: {}", + stdout + ); +} + +// ─── GPG Unlock Roundtrip ────────────────────────────────────── + +#[test] +fn test_gpg_unlock_roundtrip() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + generate_test_key(gpg_home.path(), "Liam Test", "liam@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + // Set up encrypted file + let gitattributes = dir.path().join(".gitattributes"); + fs::write(&gitattributes, "*.secret filter=git-crypt diff=git-crypt\n").unwrap(); + let secret_file = dir.path().join("data.secret"); + fs::write(&secret_file, "super-secret-value-42\n").unwrap(); + assert_success(&git(dir.path(), &["add", "."]), "git add"); + assert_success( + &git(dir.path(), &["commit", "-m", "add secrets"]), + "git commit secrets", + ); + + // Verify file is encrypted in the blob + let show_out = git(dir.path(), &["show", ":data.secret"]); + let blob = show_out.stdout; + assert!( + blob.starts_with(b"\0GITCRYPT\0"), + "file should be encrypted in blob" + ); + + // Add GPG user + assert_success( + &gitveil_gpg( + gpg_home.path(), + dir.path(), + &["add-gpg-user", "--trusted", "liam@gitveil.test"], + ), + "add-gpg-user", + ); + + // Lock + assert_success( + &gitveil_gpg(gpg_home.path(), dir.path(), &["lock", "--force"]), + "gitveil lock", + ); + + // Verify file is now encrypted in working copy + let locked_content = fs::read(&secret_file).unwrap(); + assert!( + locked_content.starts_with(b"\0GITCRYPT\0"), + "locked file should start with GITCRYPT header" + ); + + // Unlock via GPG (no key file argument!) + let out = gitveil_gpg(gpg_home.path(), dir.path(), &["unlock"]); + assert_success(&out, "gitveil unlock (GPG)"); + + // Verify file is decrypted + let decrypted = fs::read_to_string(&secret_file).unwrap(); + assert_eq!( + decrypted, "super-secret-value-42\n", + "file should be decrypted back to original plaintext" + ); +} + +// ─── Multi-User Scenario ─────────────────────────────────────── + +#[test] +fn test_add_and_remove_multiple_users() { + skip_without_gpg!(); + let gpg_home = tempfile::tempdir().unwrap(); + generate_test_key(gpg_home.path(), "Mia Test", "mia@gitveil.test"); + generate_test_key(gpg_home.path(), "Noah Test", "noah@gitveil.test"); + let dir = make_initialized_repo(gpg_home.path()); + + // Add both users + assert_success( + &gitveil_gpg( + gpg_home.path(), + dir.path(), + &["add-gpg-user", "--trusted", "mia@gitveil.test"], + ), + "add mia", + ); + assert_success( + &gitveil_gpg( + gpg_home.path(), + dir.path(), + &["add-gpg-user", "--trusted", "noah@gitveil.test"], + ), + "add noah", + ); + assert_eq!(count_gpg_files(dir.path(), "default"), 2); + + // Remove one + assert_success( + &gitveil_gpg( + gpg_home.path(), + dir.path(), + &["rm-gpg-user", "mia@gitveil.test"], + ), + "rm mia", + ); + assert_eq!(count_gpg_files(dir.path(), "default"), 1); + + // List should show 1 user + let out = gitveil_gpg(gpg_home.path(), dir.path(), &["ls-gpg-users"]); + assert_success(&out, "ls-gpg-users"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("1 user"), + "should show 1 user remaining: {}", + stdout + ); +} From ded9a139f5eb6d311b7ee18a417f7acfff19819a Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 13:27:42 +0200 Subject: [PATCH 13/19] fix: GPG tests skip gracefully on Windows CI gpg_available() now tests that GPG can operate with a custom GNUPGHOME, not just that the binary exists. On Windows CI, Git's bundled GPG reports a version but fails with MSYS2 path translation errors. --- tests/gpg_integration.rs | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/tests/gpg_integration.rs b/tests/gpg_integration.rs index 35ae23d..d130b2e 100644 --- a/tests/gpg_integration.rs +++ b/tests/gpg_integration.rs @@ -12,20 +12,48 @@ use std::process::{Command, Output}; // ─── Helpers ─────────────────────────────────────────────────── -/// Check whether gpg is available and functional on this system. +/// Check whether gpg is available and fully functional on this system. +/// This tests more than just `gpg --version` — it verifies GPG can actually +/// operate with a custom GNUPGHOME (temp directory). On Windows CI, the +/// Git-bundled GPG may report a version but fail on real operations due to +/// MSYS2 path translation issues. fn gpg_available() -> bool { - Command::new("gpg") + // First check: binary exists + let version_ok = Command::new("gpg") .args(["--version"]) .output() .map(|o| o.status.success()) + .unwrap_or(false); + + if !version_ok { + return false; + } + + // Second check: GPG can actually work with a temp GNUPGHOME. + // This catches Windows CI where path handling breaks. + let tmp = match tempfile::tempdir() { + Ok(d) => d, + Err(_) => return false, + }; + Command::new("gpg") + .args(["--batch", "--list-keys"]) + .env("GNUPGHOME", tmp.path()) + .output() + .map(|o| { + // gpg --list-keys with empty keyring returns 0 on most platforms + // (it creates the keyring). If it fails, GNUPGHOME is broken. + // Also check that it didn't error with path issues. + o.status.success() + || !String::from_utf8_lossy(&o.stderr).contains("No such file or directory") + }) .unwrap_or(false) } -/// Early-return from a test when gpg is not installed. +/// Early-return from a test when gpg is not functional. macro_rules! skip_without_gpg { () => { if !gpg_available() { - eprintln!("SKIPPED: gpg not found in PATH"); + eprintln!("SKIPPED: gpg not functional (not installed or GNUPGHOME broken)"); return; } }; From 864b0d9e366603a5073a172cde4c7158ab6dc4ad Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 13:33:55 +0200 Subject: [PATCH 14/19] ci: install standalone GPG on Windows via Chocolatey Gpg4win handles native Windows paths correctly, unlike the GPG bundled with Git for Windows (which fails with MSYS2 path issues). This enables GPG integration tests to run on all 3 platforms. --- .github/workflows/ci.yml | 4 ++++ tests/gpg_integration.rs | 22 +++++++++------------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb11475..f6c62e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,10 @@ jobs: if: runner.os == 'macOS' run: brew install git-crypt gnupg + - name: Install GPG (Windows) + if: runner.os == 'Windows' + run: choco install gnupg -y --no-progress + - name: Check formatting run: cargo fmt --check diff --git a/tests/gpg_integration.rs b/tests/gpg_integration.rs index d130b2e..ce3b4e2 100644 --- a/tests/gpg_integration.rs +++ b/tests/gpg_integration.rs @@ -14,8 +14,8 @@ use std::process::{Command, Output}; /// Check whether gpg is available and fully functional on this system. /// This tests more than just `gpg --version` — it verifies GPG can actually -/// operate with a custom GNUPGHOME (temp directory). On Windows CI, the -/// Git-bundled GPG may report a version but fail on real operations due to +/// operate with a custom GNUPGHOME (temp directory). On Windows CI without +/// standalone GPG, the Git-bundled GPG fails on real operations due to /// MSYS2 path translation issues. fn gpg_available() -> bool { // First check: binary exists @@ -30,23 +30,19 @@ fn gpg_available() -> bool { } // Second check: GPG can actually work with a temp GNUPGHOME. - // This catches Windows CI where path handling breaks. + // Run --list-keys which creates the keyring files if GNUPGHOME works. let tmp = match tempfile::tempdir() { Ok(d) => d, Err(_) => return false, }; - Command::new("gpg") + let _ = Command::new("gpg") .args(["--batch", "--list-keys"]) .env("GNUPGHOME", tmp.path()) - .output() - .map(|o| { - // gpg --list-keys with empty keyring returns 0 on most platforms - // (it creates the keyring). If it fails, GNUPGHOME is broken. - // Also check that it didn't error with path issues. - o.status.success() - || !String::from_utf8_lossy(&o.stderr).contains("No such file or directory") - }) - .unwrap_or(false) + .output(); + + // Proof that GPG handled the path: it created a keyring file. + tmp.path().join("trustdb.gpg").exists() + || tmp.path().join("pubring.kbx").exists() } /// Early-return from a test when gpg is not functional. From 6b14c61cd297ee2896ec270c0d49b77ab7119526 Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 13:36:57 +0200 Subject: [PATCH 15/19] style: fix formatting in gpg_integration.rs --- tests/gpg_integration.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/gpg_integration.rs b/tests/gpg_integration.rs index ce3b4e2..3b9a3c7 100644 --- a/tests/gpg_integration.rs +++ b/tests/gpg_integration.rs @@ -41,8 +41,7 @@ fn gpg_available() -> bool { .output(); // Proof that GPG handled the path: it created a keyring file. - tmp.path().join("trustdb.gpg").exists() - || tmp.path().join("pubring.kbx").exists() + tmp.path().join("trustdb.gpg").exists() || tmp.path().join("pubring.kbx").exists() } /// Early-return from a test when gpg is not functional. From c0a5d7b043fb80045eeb2cebed426ece98a21516 Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 13:53:09 +0200 Subject: [PATCH 16/19] ci: use winget for faster GPG install on Windows --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6c62e4..cff2407 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,12 @@ jobs: - name: Install GPG (Windows) if: runner.os == 'Windows' - run: choco install gnupg -y --no-progress + shell: pwsh + run: | + # Install GnuPG via winget (fast, native Windows build) + winget install --id GnuPG.GnuPG -e --accept-source-agreements --accept-package-agreements --silent + # Add to PATH for subsequent steps + echo "C:\Program Files (x86)\GnuPG\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Check formatting run: cargo fmt --check From 0743de3ee907688d4d46fd01e67f0428f2dadb64 Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 14:02:29 +0200 Subject: [PATCH 17/19] ci: install GnuPG on Windows via direct download (faster than winget/choco) --- .github/workflows/ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cff2407..aa91862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,9 +46,10 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - # Install GnuPG via winget (fast, native Windows build) - winget install --id GnuPG.GnuPG -e --accept-source-agreements --accept-package-agreements --silent - # Add to PATH for subsequent steps + $url = "https://gnupg.org/ftp/gcrypt/binary/gnupg-w32-2.4.7_20250114.exe" + $installer = "$env:RUNNER_TEMP\gnupg-installer.exe" + Invoke-WebRequest -Uri $url -OutFile $installer -UseBasicParsing + Start-Process -FilePath $installer -ArgumentList "/S" -Wait -NoNewWindow echo "C:\Program Files (x86)\GnuPG\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Check formatting From 9e70f087cc4d0951cbd53ad47de83cb59fdb7f6e Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 14:07:24 +0200 Subject: [PATCH 18/19] ci: fix GnuPG Windows installer URL (use 2.4.8 stable) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa91862..ecf587e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - $url = "https://gnupg.org/ftp/gcrypt/binary/gnupg-w32-2.4.7_20250114.exe" + $url = "https://gnupg.org/ftp/gcrypt/binary/gnupg-w32-2.4.8_20250514.exe" $installer = "$env:RUNNER_TEMP\gnupg-installer.exe" Invoke-WebRequest -Uri $url -OutFile $installer -UseBasicParsing Start-Process -FilePath $installer -ArgumentList "/S" -Wait -NoNewWindow From 5d646f96e9a6b37fb086a74597014391ec6460cf Mon Sep 17 00:00:00 2001 From: Luca Tescari Date: Wed, 15 Apr 2026 14:14:35 +0200 Subject: [PATCH 19/19] ci: use scoop for fast GPG install on Windows --- .github/workflows/ci.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecf587e..ebaf6f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,11 +46,12 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - $url = "https://gnupg.org/ftp/gcrypt/binary/gnupg-w32-2.4.8_20250514.exe" - $installer = "$env:RUNNER_TEMP\gnupg-installer.exe" - Invoke-WebRequest -Uri $url -OutFile $installer -UseBasicParsing - Start-Process -FilePath $installer -ArgumentList "/S" -Wait -NoNewWindow - echo "C:\Program Files (x86)\GnuPG\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force + if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) { + Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression + } + scoop install gnupg + gpg --version - name: Check formatting run: cargo fmt --check