diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01ddc34..ebaf6f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,13 +34,24 @@ 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: Install GPG (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + 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 @@ -54,5 +65,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 0d2f7ab..48471bb 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 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 @@ -36,6 +36,15 @@ All 54 tests should pass (28 unit + 20 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) +- 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 @@ -60,16 +69,18 @@ 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 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/README.md b/README.md index b06e41c..96c3a53 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,37 @@ 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 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` Remove a GPG user's access. @@ -309,6 +342,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 +364,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/ 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/add_gpg_user.rs b/src/commands/add_gpg_user.rs index 79df250..c002c40 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,29 @@ 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(), ) })?; 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 new file mode 100644 index 0000000..65fc992 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,248 @@ +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() + ))); + } + + // 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. +/// 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, &format!("{}\n", canonical.to_string_lossy()))?; + + 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 std::sync::Mutex; + use tempfile::TempDir; + + // 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(()); + + /// 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: 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 } + } + } + + impl Drop for XdgGuard { + fn drop(&mut self) { + // 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") }, + } + } + } + + #[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(); + assert_eq!(dir, tmp.path().join("gitveil")); + } + + #[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()); + + 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); + } + + #[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(); + assert!(loaded.is_none()); + } +} 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() { diff --git a/src/main.rs b/src/main.rs index 5005e2a..e9d941b 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; @@ -70,6 +71,12 @@ 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(()) diff --git a/tests/gpg_integration.rs b/tests/gpg_integration.rs new file mode 100644 index 0000000..3b9a3c7 --- /dev/null +++ b/tests/gpg_integration.rs @@ -0,0 +1,727 @@ +//! 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 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 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 + 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. + // Run --list-keys which creates the keyring files if GNUPGHOME works. + let tmp = match tempfile::tempdir() { + Ok(d) => d, + Err(_) => return false, + }; + let _ = Command::new("gpg") + .args(["--batch", "--list-keys"]) + .env("GNUPGHOME", tmp.path()) + .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. +macro_rules! skip_without_gpg { + () => { + if !gpg_available() { + eprintln!("SKIPPED: gpg not functional (not installed or GNUPGHOME broken)"); + 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 + ); +} diff --git a/tests/integration.rs b/tests/integration.rs index 60c3acc..e5a1fcf 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -740,3 +740,654 @@ 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(); + let gpg_home = 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. + // 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)" + ); + 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 + ); +}