From be8dce6e9c14ef90ae1f8de143c06ae0488fc6c0 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Sat, 1 Aug 2026 16:13:47 +0000 Subject: [PATCH] test(grasp-audit): route git subprocesses through hermetic git_command Fixture behaviour and the hard-coded deterministic commit hashes were only stable because the host's git configuration happened to be benign. Empirically, a failing pre-commit hook delivered via core.hooksPath or init.templateDir (including from the XDG config location) breaks every fixture commit, and init.defaultBranch renames branches fixtures rely on; per-site "-c" flags and GIT_TERMINAL_PROMPT covered only fragments of that surface. Add grasp_audit::git_command(), which masks the global and system config files via GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM=/dev/null (requires git >= 2.32; the flake pins 2.54), disables terminal prompts, and clears the GIT_CONFIG_COUNT/GIT_CONFIG_PARAMETERS injection vectors plus inherited GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE. Every git invocation in fixtures and specs now goes through it, so an invocation sees only configuration it supplies itself (repo-local config, -c flags, or env vars at the call site). Redundant per-site GIT_TERMINAL_PROMPT pins are dropped. A regression test first proves a deliberately hostile global config (defaultBranch=main, failing hooks via hooksPath and templateDir, gpgsign, autocrlf) breaks a non-hermetic commit, then proves the hermetic recipe still yields DETERMINISTIC_COMMIT_HASH with that config exposed through the process environment. The test waits only on process exit, no sleeps. Excluded scope: ngit-grasp's tests/ adopt the helper separately, and swallowed git exit statuses are fixed separately; behaviour of the relay itself is unchanged. Validated: cargo clippy -p grasp-audit --all-targets -D warnings; cargo test -p grasp-audit --lib (37 passed). --- grasp-audit/src/fixtures.rs | 67 +++--- grasp-audit/src/git.rs | 204 ++++++++++++++++++ grasp-audit/src/lib.rs | 2 + grasp-audit/src/specs/grasp01/git_clone.rs | 7 +- grasp-audit/src/specs/grasp01/git_filter.rs | 11 +- .../src/specs/grasp01/push_authorization.rs | 41 ++-- grasp-audit/src/specs/grasp06/mirroring.rs | 11 +- grasp-audit/src/specs/grasp06/prs_endpoint.rs | 16 +- 8 files changed, 273 insertions(+), 86 deletions(-) create mode 100644 grasp-audit/src/git.rs diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs index 1c9d067..6847d56 100644 --- a/grasp-audit/src/fixtures.rs +++ b/grasp-audit/src/fixtures.rs @@ -1314,7 +1314,7 @@ impl<'a> TestContext<'a> { // -B creates or resets main at HEAD, tolerating a pre-existing local // main (e.g. when the host sets init.defaultBranch=main) - let _ = Command::new("git") + let _ = git_command() .args(["checkout", "-B", "main"]) .current_dir(&clone_path) .output(); @@ -1416,7 +1416,7 @@ impl<'a> TestContext<'a> { // Create or reset main at our deterministic commit and check it out. // -B tolerates a pre-existing local main (e.g. when the host sets // init.defaultBranch=main) - let checkout_output = Command::new("git") + let checkout_output = git_command() .args(["checkout", "-B", "main"]) .current_dir(&clone_path) .output(); @@ -1553,13 +1553,13 @@ impl<'a> TestContext<'a> { // Reset to orphan state and create deterministic root commit // Step 1: Create orphan branch (removes all history) - let _ = Command::new("git") + let _ = git_command() .args(["checkout", "--orphan", "main-new"]) .current_dir(&clone_path) .output(); // Step 2: Clear staged files (orphan keeps files staged from previous branch) - let _ = Command::new("git") + let _ = git_command() .args(["rm", "-rf", "--cached", "."]) .current_dir(&clone_path) .output(); @@ -1577,12 +1577,12 @@ impl<'a> TestContext<'a> { }; // Step 4: Replace main branch with our new orphan branch - let _ = Command::new("git") + let _ = git_command() .args(["branch", "-D", "main"]) .current_dir(&clone_path) .output(); - let _ = Command::new("git") + let _ = git_command() .args(["branch", "-m", "main"]) .current_dir(&clone_path) .output(); @@ -1730,13 +1730,13 @@ impl<'a> TestContext<'a> { // Reset to orphan state and create deterministic root commit // Step 1: Create orphan branch (removes all history) - let _ = Command::new("git") + let _ = git_command() .args(["checkout", "--orphan", "main-new"]) .current_dir(&clone_path) .output(); // Step 2: Clear staged files (orphan keeps files staged from previous branch) - let _ = Command::new("git") + let _ = git_command() .args(["rm", "-rf", "--cached", "."]) .current_dir(&clone_path) .output(); @@ -1757,12 +1757,12 @@ impl<'a> TestContext<'a> { }; // Step 4: Replace main branch with our new orphan branch - let _ = Command::new("git") + let _ = git_command() .args(["branch", "-D", "main"]) .current_dir(&clone_path) .output(); - let _ = Command::new("git") + let _ = git_command() .args(["branch", "-m", "main"]) .current_dir(&clone_path) .output(); @@ -1937,12 +1937,12 @@ impl<'a> TestContext<'a> { } // Create master branch if needed and push to refs/nostr/ - let _ = Command::new("git") + let _ = git_command() .args(["branch", "-M", "master"]) .current_dir(&clone_path) .output(); - let push_output = Command::new("git") + let push_output = git_command() .args([ "push", "origin", @@ -2070,13 +2070,13 @@ impl<'a> TestContext<'a> { // Reset to orphan state and create deterministic root commit // Step 1: Create orphan branch (removes all history) - let _ = Command::new("git") + let _ = git_command() .args(["checkout", "--orphan", "pr-branch"]) .current_dir(&clone_path) .output(); // Step 2: Clear staged files (orphan keeps files staged from previous branch) - let _ = Command::new("git") + let _ = git_command() .args(["rm", "-rf", "--cached", "."]) .current_dir(&clone_path) .output(); @@ -2112,7 +2112,7 @@ impl<'a> TestContext<'a> { )); } - let push_output = Command::new("git") + let push_output = git_command() .args([ "push", "origin", @@ -2335,9 +2335,9 @@ pub async fn send_and_verify_rejected( // Git Operation Helpers // ============================================================ +use crate::git_command; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use std::time::Duration; /// Clone a repository from the relay and return the path @@ -2368,9 +2368,8 @@ pub fn clone_repo(relay_domain: &str, npub: &str, repo_id: &str) -> Result Result Result fs::write(&test_file, message).map_err(|e| format!("Failed to write file: {}", e))?; let filename = test_file.file_name().unwrap().to_str().unwrap(); - let output = Command::new("git") + let output = git_command() .args(["add", filename]) .current_dir(clone_path) .output() @@ -2427,7 +2426,7 @@ pub fn create_commit(clone_path: &Path, message: &str) -> Result return Err("Git add failed".to_string()); } - let output = Command::new("git") + let output = git_command() .args(["-c", "commit.gpgsign=false", "commit", "-m", message]) .current_dir(clone_path) .output() @@ -2437,7 +2436,7 @@ pub fn create_commit(clone_path: &Path, message: &str) -> Result return Err("Git commit failed".to_string()); } - let output = Command::new("git") + let output = git_command() .args(["rev-parse", "HEAD"]) .current_dir(clone_path) .output() @@ -2514,7 +2513,7 @@ pub fn create_deterministic_commit_with_variant( fs::write(&test_file, content).map_err(|e| format!("Failed to write file: {}", e))?; - let output = Command::new("git") + let output = git_command() .args(["add", "test.txt"]) .current_dir(clone_path) .output() @@ -2525,7 +2524,7 @@ pub fn create_deterministic_commit_with_variant( } // Create deterministic commit with fixed dates and GPG disabled - let output = Command::new("git") + let output = git_command() .args(["-c", "commit.gpgsign=false", "commit", "-m", message]) .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z") .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z") @@ -2538,7 +2537,7 @@ pub fn create_deterministic_commit_with_variant( return Err(format!("Git commit failed: {}", stderr)); } - let output = Command::new("git") + let output = git_command() .args(["rev-parse", "HEAD"]) .current_dir(clone_path) .output() @@ -2594,10 +2593,9 @@ pub fn create_deterministic_commit(clone_path: &Path, _message: &str) -> Result< /// # } /// ``` pub fn try_push(clone_path: &Path) -> Result { - let output = Command::new("git") + let output = git_command() .args(["push", "origin", "main", "--force"]) .current_dir(clone_path) - .env("GIT_TERMINAL_PROMPT", "0") .output() .map_err(|e| format!("Failed to execute git push: {}", e))?; @@ -2632,10 +2630,9 @@ pub fn try_push(clone_path: &Path) -> Result { /// # } /// ``` pub fn try_push_to_ref(clone_path: &Path, ref_name: &str) -> Result { - let output = Command::new("git") + let output = git_command() .args(["push", "origin", &format!("HEAD:{}", ref_name)]) .current_dir(clone_path) - .env("GIT_TERMINAL_PROMPT", "0") .output() .map_err(|e| format!("Failed to execute git push: {}", e))?; @@ -2653,7 +2650,7 @@ pub fn try_push_to_ref(clone_path: &Path, ref_name: &str) -> Result Result<(), String> { // Step 1: git init - let output = Command::new("git") + let output = git_command() .args(["init", path.to_str().unwrap_or(".")]) .output() .map_err(|e| format!("Failed to execute git init: {}", e))?; @@ -2664,7 +2661,7 @@ pub fn init_local_repo(path: &Path, remote_url: &str) -> Result<(), String> { } // Step 2: git config user.email - let output = Command::new("git") + let output = git_command() .args(["config", "user.email", "probe@grasp-audit.local"]) .current_dir(path) .output() @@ -2676,7 +2673,7 @@ pub fn init_local_repo(path: &Path, remote_url: &str) -> Result<(), String> { } // Step 3: git config user.name - let output = Command::new("git") + let output = git_command() .args(["config", "user.name", "GRASP Probe"]) .current_dir(path) .output() @@ -2688,7 +2685,7 @@ pub fn init_local_repo(path: &Path, remote_url: &str) -> Result<(), String> { } // Step 4: git symbolic-ref HEAD refs/heads/main (sets default branch to main) - let output = Command::new("git") + let output = git_command() .args(["symbolic-ref", "HEAD", "refs/heads/main"]) .current_dir(path) .output() @@ -2700,7 +2697,7 @@ pub fn init_local_repo(path: &Path, remote_url: &str) -> Result<(), String> { } // Step 5: git remote add origin - let output = Command::new("git") + let output = git_command() .args(["remote", "add", "origin", remote_url]) .current_dir(path) .output() diff --git a/grasp-audit/src/git.rs b/grasp-audit/src/git.rs new file mode 100644 index 0000000..7b40911 --- /dev/null +++ b/grasp-audit/src/git.rs @@ -0,0 +1,204 @@ +//! Hermetic git subprocess construction. +//! +//! Fixtures and tests spawn many `git` subprocesses and several of them feed +//! hard-coded deterministic commit hashes. Those hashes (and the surrounding +//! control flow) are only meaningful if every input git reads is supplied by +//! the fixture itself rather than inherited from whatever the host happens to +//! have configured. + +use std::process::Command; + +/// Build a `git` [`Command`] that is hermetic with respect to ambient git +/// configuration. +/// +/// Ambient configuration — the system `/etc/gitconfig`, the user's +/// `~/.gitconfig` or `$XDG_CONFIG_HOME/git/config`, and config injected via +/// the `GIT_CONFIG_COUNT` / `GIT_CONFIG_PARAMETERS` environment — can change +/// fixture behaviour: a failing hook reached through `core.hooksPath` or +/// `init.templateDir` breaks every commit, `init.defaultBranch` renames +/// branches fixtures rely on, and credential helpers can stall network +/// operations waiting for input. +/// +/// Every git subprocess spawned by fixtures or tests must be built through +/// this helper. The invocation then sees only configuration it supplies +/// itself: repo-local `git config`, `-c` flags, or `GIT_*` environment +/// variables set at the call site. +/// +/// Requires git >= 2.32 (June 2021) for `GIT_CONFIG_GLOBAL` / +/// `GIT_CONFIG_SYSTEM`; the flake pins a far newer git. +pub fn git_command() -> Command { + let mut cmd = Command::new("git"); + // Mask the system config and the user-global config in both of its + // locations (~/.gitconfig and $XDG_CONFIG_HOME/git/config). + cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); + cmd.env("GIT_CONFIG_SYSTEM", "/dev/null"); + // Fail fast instead of stalling the suite on a credential prompt. + cmd.env("GIT_TERMINAL_PROMPT", "0"); + // Config can also be injected through the environment. GIT_CONFIG_KEY_n / + // GIT_CONFIG_VALUE_n are only honoured while GIT_CONFIG_COUNT is set, so + // removing the count neutralises the whole family. + cmd.env_remove("GIT_CONFIG_COUNT"); + cmd.env_remove("GIT_CONFIG_PARAMETERS"); + // Repository discovery must come from `current_dir`, not from a parent + // process that happens to run inside a git hook, alias, or rebase. + cmd.env_remove("GIT_DIR"); + cmd.env_remove("GIT_WORK_TREE"); + cmd.env_remove("GIT_INDEX_FILE"); + cmd +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::{ + create_deterministic_commit_with_variant, CommitVariant, DETERMINISTIC_COMMIT_HASH, + }; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; + + /// Write a global gitconfig whose settings demonstrably break the fixture + /// recipe when they leak in: `core.hooksPath` and `init.templateDir` both + /// deliver a pre-commit hook that always fails, `init.defaultBranch` + /// renames the initial branch, and gpgsign/autocrlf cover the remaining + /// commit inputs. + fn write_hostile_gitconfig(dir: &Path) -> PathBuf { + let hooks = dir.join("hooks"); + fs::create_dir_all(&hooks).expect("create hooks dir"); + let hook = hooks.join("pre-commit"); + fs::write( + &hook, + "#!/bin/sh\necho 'hostile ambient git config leaked into a fixture' >&2\nexit 1\n", + ) + .expect("write pre-commit hook"); + fs::set_permissions(&hook, fs::Permissions::from_mode(0o755)).expect("chmod hook"); + + let template_hooks = dir.join("template").join("hooks"); + fs::create_dir_all(&template_hooks).expect("create template hooks dir"); + fs::copy(&hook, template_hooks.join("pre-commit")).expect("copy hook into template"); + + let config = dir.join("gitconfig"); + fs::write( + &config, + format!( + "[init]\n\ + \tdefaultBranch = main\n\ + \ttemplateDir = {template}\n\ + [core]\n\ + \thooksPath = {hooks}\n\ + \tautocrlf = true\n\ + [commit]\n\ + \tgpgsign = true\n", + template = dir.join("template").display(), + hooks = hooks.display(), + ), + ) + .expect("write hostile gitconfig"); + config + } + + /// Restores the mutated process environment even if the test panics. + struct EnvGuard { + saved: Vec<(&'static str, Option)>, + } + + impl EnvGuard { + fn set(vars: &[(&'static str, &Path)]) -> Self { + let saved = vars + .iter() + .map(|(key, value)| { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + (*key, previous) + }) + .collect(); + Self { saved } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, previous) in self.saved.drain(..) { + match previous { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } + + fn init_repo_with_identity(git: impl Fn() -> Command, repo: &Path) { + let init = git() + .args(["init", repo.to_str().expect("utf-8 repo path")]) + .output() + .expect("spawn git init"); + assert!( + init.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&init.stderr) + ); + for (key, value) in [ + ("user.email", "test@grasp-audit.local"), + ("user.name", "GRASP Audit Test"), + ] { + let config = git() + .args(["config", key, value]) + .current_dir(repo) + .output() + .expect("spawn git config"); + assert!( + config.status.success(), + "git config {key} failed: {}", + String::from_utf8_lossy(&config.stderr) + ); + } + } + + /// Regression test for hermeticity: the deterministic commit fixture must + /// produce its pinned hash even when the surrounding process advertises a + /// hostile git configuration. + /// + /// The test first proves the hostile configuration is potent (a plain git + /// invocation honouring it cannot commit at all), then proves + /// [`git_command`]-based fixtures neutralise it. + #[test] + fn deterministic_commit_survives_hostile_ambient_git_config() { + let dir = tempfile::tempdir().expect("create temp dir"); + let hostile_config = write_hostile_gitconfig(dir.path()); + + // Potency check: a non-hermetic git honouring the hostile config must + // fail to commit (the pre-commit hook fires). If this stops failing, + // the hostile config has rotted and no longer guards anything. + let raw_repo = dir.path().join("raw"); + let raw_git = || { + let mut cmd = Command::new("git"); + cmd.env("GIT_CONFIG_GLOBAL", &hostile_config); + cmd.env("GIT_CONFIG_SYSTEM", "/dev/null"); + cmd + }; + init_repo_with_identity(raw_git, &raw_repo); + let err = create_deterministic_commit_with_variant(&raw_repo, CommitVariant::Owner) + .expect_err("hostile config should break a commit that inherits it"); + assert!( + err.contains("hostile ambient git config leaked"), + "expected the hostile hook to fire, got: {err}" + ); + + // Hermetic check: with the hostile config exposed the way a real host + // exposes it (global/system config paths in the environment), the + // fixture recipe must still produce the pinned deterministic hash. + let _guard = EnvGuard::set(&[ + ("HOME", dir.path()), + ("GIT_CONFIG_GLOBAL", &hostile_config), + ("GIT_CONFIG_SYSTEM", &hostile_config), + ]); + let hermetic_repo = dir.path().join("hermetic"); + init_repo_with_identity(git_command, &hermetic_repo); + let hash = create_deterministic_commit_with_variant(&hermetic_repo, CommitVariant::Owner) + .expect("hermetic fixture commit should succeed under hostile ambient config"); + assert_eq!( + hash, DETERMINISTIC_COMMIT_HASH, + "deterministic hash must not depend on ambient git configuration" + ); + } +} diff --git a/grasp-audit/src/lib.rs b/grasp-audit/src/lib.rs index 2a0035b..696e627 100644 --- a/grasp-audit/src/lib.rs +++ b/grasp-audit/src/lib.rs @@ -31,6 +31,7 @@ pub mod audit; pub mod client; pub mod fixtures; +pub mod git; pub mod isolation; pub mod probe; pub mod result; @@ -62,6 +63,7 @@ pub use fixtures::{ PR_TEST_COMMIT_HASH, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH, }; +pub use git::git_command; pub use probe::{run_probe, ProbeCheck, ProbeReport}; pub use result::{AuditResult, TestResult}; diff --git a/grasp-audit/src/specs/grasp01/git_clone.rs b/grasp-audit/src/specs/grasp01/git_clone.rs index 3823628..ac6b3b5 100644 --- a/grasp-audit/src/specs/grasp01/git_clone.rs +++ b/grasp-audit/src/specs/grasp01/git_clone.rs @@ -15,11 +15,11 @@ //! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test //! ``` +use crate::git_command; use crate::specs::grasp01::SpecRef; use crate::{AuditClient, FixtureKind, TestContext, TestResult}; use nostr_sdk::prelude::*; use std::fs; -use std::process::Command; /// Test suite for Git clone operations pub struct GitCloneTests; @@ -106,7 +106,7 @@ impl GitCloneTests { let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); // Attempt to clone the repository - let output = Command::new("git") + let output = git_command() .args(["clone", &clone_url, clone_path.to_str().unwrap()]) .env("GIT_TERMINAL_PROMPT", "0") // Disable password prompts .output(); @@ -229,9 +229,8 @@ impl GitCloneTests { let invalid_url = format!("http://{}/invalid/path", relay_domain); - let output = Command::new("git") + let output = git_command() .args(["clone", &invalid_url, clone_path.to_str().unwrap()]) - .env("GIT_TERMINAL_PROMPT", "0") .output() .unwrap(); diff --git a/grasp-audit/src/specs/grasp01/git_filter.rs b/grasp-audit/src/specs/grasp01/git_filter.rs index 5c8a995..d02c108 100644 --- a/grasp-audit/src/specs/grasp01/git_filter.rs +++ b/grasp-audit/src/specs/grasp01/git_filter.rs @@ -22,11 +22,11 @@ //! cd grasp-audit && nix develop -c bash test-ngit-relay.sh --mode test //! ``` +use crate::git_command; use crate::specs::grasp01::SpecRef; use crate::{AuditClient, FixtureKind, TestContext, TestResult}; use nostr_sdk::prelude::*; use std::fs; -use std::process::Command; /// Test suite for Git filter capability operations pub struct GitFilterTests; @@ -223,14 +223,13 @@ impl GitFilterTests { let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); // Attempt filtered clone with blob:none - let output = Command::new("git") + let output = git_command() .args([ "clone", "--filter=blob:none", &clone_url, clone_path.to_str().unwrap(), ]) - .env("GIT_TERMINAL_PROMPT", "0") .output(); // Clean up @@ -334,14 +333,13 @@ impl GitFilterTests { let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id); // First do a shallow clone to have a repository to fetch into - let clone_output = Command::new("git") + let clone_output = git_command() .args([ "clone", "--depth=1", &clone_url, clone_path.to_str().unwrap(), ]) - .env("GIT_TERMINAL_PROMPT", "0") .output(); // Clean up @@ -360,10 +358,9 @@ impl GitFilterTests { } // Now attempt a filtered fetch - let output = Command::new("git") + let output = git_command() .args(["fetch", "--filter=tree:0", "origin"]) .current_dir(&clone_path) - .env("GIT_TERMINAL_PROMPT", "0") .output(); let output = match output { diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs index 50433a0..878d1de 100644 --- a/grasp-audit/src/specs/grasp01/push_authorization.rs +++ b/grasp-audit/src/specs/grasp01/push_authorization.rs @@ -31,6 +31,7 @@ #[allow(dead_code)] const PR_TEST_COMMIT_HASH: &str = "5a51b30e4615b572dcd5b9e487861b58605a5c21"; +use crate::git_command; use crate::specs::grasp01::SpecRef; use crate::{ clone_repo, create_commit, create_deterministic_commit_with_variant, try_push, try_push_to_ref, @@ -40,7 +41,6 @@ use crate::{ use nostr_sdk::prelude::*; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use std::time::Duration; // ============================================================ @@ -66,13 +66,13 @@ use std::time::Duration; fn create_pr_test_commit(clone_path: &Path) -> Result { // Step 1: Clean up any tracked files in the working directory // This ensures we start with a clean slate - let _ = Command::new("git") + let _ = git_command() .args(["clean", "-fd"]) .current_dir(clone_path) .output(); // Step 2: Create orphan branch (removes all history) - let output = Command::new("git") + let output = git_command() .args(["checkout", "--orphan", "pr-test-branch"]) .current_dir(clone_path) .output() @@ -86,7 +86,7 @@ fn create_pr_test_commit(clone_path: &Path) -> Result { } // Step 3: Remove ALL files from the index (staging area) - let output = Command::new("git") + let output = git_command() .args(["rm", "-rf", "--cached", "."]) .current_dir(clone_path) .output() @@ -123,7 +123,7 @@ fn create_pr_test_commit(clone_path: &Path) -> Result { create_deterministic_commit_with_variant(clone_path, CommitVariant::PRTestCommit)?; // Step 6: Verify this is actually a root commit (no parent) - let output = Command::new("git") + let output = git_command() .args(["rev-list", "--max-parents=0", "HEAD"]) .current_dir(clone_path) .output() @@ -139,12 +139,12 @@ fn create_pr_test_commit(clone_path: &Path) -> Result { } // Step 7: Replace main branch with our new orphan branch - let _ = Command::new("git") + let _ = git_command() .args(["branch", "-D", "main"]) .current_dir(clone_path) .output(); - let output = Command::new("git") + let output = git_command() .args(["branch", "-m", "main"]) .current_dir(clone_path) .output() @@ -160,7 +160,7 @@ fn create_pr_test_commit(clone_path: &Path) -> Result { // Step 8: Verify commit hash matches expected if commit_hash != PR_TEST_COMMIT_HASH { // Debug: Show what's in the commit - let tree_output = Command::new("git") + let tree_output = git_command() .args(["ls-tree", "-r", "HEAD"]) .current_dir(clone_path) .output(); @@ -168,7 +168,7 @@ fn create_pr_test_commit(clone_path: &Path) -> Result { .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) .unwrap_or_else(|_| "Failed to get tree".to_string()); - let cat_output = Command::new("git") + let cat_output = git_command() .args(["cat-file", "-p", "HEAD"]) .current_dir(clone_path) .output(); @@ -254,7 +254,7 @@ fn reset_to_correct_pr_commit(clone_path: &Path) -> Result { /// Returns Ok(true) if push succeeded, Ok(false) if rejected, Err on git error. #[allow(dead_code)] fn push_to_pr_ref(clone_path: &Path, pr_event_id: &str) -> Result { - let push_output = Command::new("git") + let push_output = git_command() .args([ "push", "--force", @@ -332,7 +332,7 @@ async fn get_default_branch_from_info_refs( /// Checks if a ref exists on the remote. #[allow(dead_code)] fn ref_exists_on_remote(clone_path: &Path, ref_name: &str) -> Result { - let output = Command::new("git") + let output = git_command() .args(["ls-remote", "origin", ref_name]) .current_dir(clone_path) .output() @@ -541,8 +541,6 @@ impl PushAuthorizationTests { client: &AuditClient, relay_domain: &str, ) -> TestResult { - use std::process::Command; - let test_name = "test_push_rejected_wrong_commit"; // ============================================================ @@ -617,7 +615,7 @@ impl PushAuthorizationTests { }; // Create/checkout main branch - let branch_output = Command::new("git") + let branch_output = git_command() .args(["checkout", "-B", "main"]) .current_dir(&clone_path) .output(); @@ -1568,7 +1566,7 @@ impl PushAuthorizationTests { // ============================================================ // Step 4: Create and checkout develop1 branch, then create unique commit // ============================================================ - let output = Command::new("git") + let output = git_command() .args(["checkout", "-b", "develop1"]) .current_dir(&clone_path) .output(); @@ -1683,14 +1681,13 @@ mod tests { #[test] fn test_pr_test_commit_hash_discovery() { use std::fs; - use std::process::Command; use tempfile::TempDir; let temp_dir = TempDir::new().expect("Failed to create temp dir"); let path = temp_dir.path(); // Initialize git repo - let output = Command::new("git") + let output = git_command() .args(["init"]) .current_dir(path) .output() @@ -1702,14 +1699,14 @@ mod tests { ); // Configure git user - use same identity as clone_repo in fixtures.rs - let output = Command::new("git") + let output = git_command() .args(["config", "user.email", "test@grasp-audit.local"]) .current_dir(path) .output() .expect("git config email failed"); assert!(output.status.success(), "git config email failed"); - let output = Command::new("git") + let output = git_command() .args(["config", "user.name", "GRASP Audit Test"]) .current_dir(path) .output() @@ -1721,7 +1718,7 @@ mod tests { fs::write(&test_file, "PR test deterministic commit\n").expect("Failed to write test file"); // Add the file - let output = Command::new("git") + let output = git_command() .args(["add", "test.txt"]) .current_dir(path) .output() @@ -1733,7 +1730,7 @@ mod tests { ); // Create deterministic commit with fixed dates and GPG disabled - let output = Command::new("git") + let output = git_command() .args([ "-c", "commit.gpgsign=false", @@ -1753,7 +1750,7 @@ mod tests { ); // Get the commit hash - let output = Command::new("git") + let output = git_command() .args(["rev-parse", "HEAD"]) .current_dir(path) .output() diff --git a/grasp-audit/src/specs/grasp06/mirroring.rs b/grasp-audit/src/specs/grasp06/mirroring.rs index 2faf686..e41c887 100644 --- a/grasp-audit/src/specs/grasp06/mirroring.rs +++ b/grasp-audit/src/specs/grasp06/mirroring.rs @@ -48,6 +48,7 @@ //! sleep before the ref-mirror probe — keeps the total wait close to //! the original 2 s while making the two failure modes attributable. +use crate::git_command; use crate::specs::grasp06::SpecRef; use crate::{ create_commit, init_local_repo, try_push_to_ref, AuditClient, FixtureKind, TestContext, @@ -56,7 +57,6 @@ use crate::{ use nostr_sdk::prelude::*; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use std::time::Duration; pub struct MirroringTests; @@ -619,9 +619,8 @@ impl MirroringTests { // had ample time to run. A negative result here is a real // "didn't happen, won't happen" signal. let refname = format!("refs/nostr/{}", pr_event_id); - let out = Command::new("git") + let out = git_command() .args(["ls-remote", &prs_url, &refname]) - .env("GIT_TERMINAL_PROMPT", "0") .output() .map_err(|e| format!("Failed to execute git ls-remote {}: {}", prs_url, e))?; @@ -707,9 +706,5 @@ impl Drop for TempPath { /// Run a `git` command in `cwd` and return the [`std::process::Output`] /// unchanged. The caller decides whether non-zero exit is failure. fn run_git(cwd: &Path, args: &[&str]) -> std::io::Result { - Command::new("git") - .args(args) - .current_dir(cwd) - .env("GIT_TERMINAL_PROMPT", "0") - .output() + git_command().args(args).current_dir(cwd).output() } diff --git a/grasp-audit/src/specs/grasp06/prs_endpoint.rs b/grasp-audit/src/specs/grasp06/prs_endpoint.rs index 8d6b729..d93c93f 100644 --- a/grasp-audit/src/specs/grasp06/prs_endpoint.rs +++ b/grasp-audit/src/specs/grasp06/prs_endpoint.rs @@ -5,6 +5,7 @@ //! Each test here maps 1:1 to a MUST in the spec, or to an audit-derived //! invariant that follows directly from NIP-11 discovery semantics. +use crate::git_command; use crate::specs::grasp06::fixtures::advertises_grasp; use crate::specs::grasp06::SpecRef; use crate::{ @@ -13,7 +14,6 @@ use crate::{ use nostr_sdk::prelude::*; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use std::time::Duration; pub struct PrsEndpointTests; @@ -164,9 +164,8 @@ impl PrsEndpointTests { let _ = fs::remove_dir_all(&clone_path); }; - let clone_output = Command::new("git") + let clone_output = git_command() .args(["clone", &clone_url, clone_path.to_str().unwrap()]) - .env("GIT_TERMINAL_PROMPT", "0") .output(); let clone_output = match clone_output { @@ -186,7 +185,7 @@ impl PrsEndpointTests { // 4. Verify the cloned repo has zero refs. `for-each-ref` lists // every ref one-per-line; empty stdout means no refs at all, // which is the spec's "empty bare repository" invariant. - let refs_output = Command::new("git") + let refs_output = git_command() .args(["-C", clone_path.to_str().unwrap(), "for-each-ref"]) .output(); @@ -520,9 +519,8 @@ impl PushValidationTests { // empty stdout means the ref was deleted (or never written). // A failed ls-remote (404 / connection error) is a setup // failure — the /prs/ endpoint must be reachable. - let ls_out = Command::new("git") + let ls_out = git_command() .args(["ls-remote", &prs_url, &refname]) - .env("GIT_TERMINAL_PROMPT", "0") .output() .map_err(|e| format!("Failed to execute git ls-remote {}: {}", prs_url, e))?; @@ -670,10 +668,9 @@ impl Drop for LocalWorkspace2 { /// Run a `git` command in `cwd` and require success. Returns the stderr text /// on failure so the caller's error message contains the actual git output. fn run_git(cwd: &Path, args: &[&str]) -> Result<(), String> { - let output = Command::new("git") + let output = git_command() .args(args) .current_dir(cwd) - .env("GIT_TERMINAL_PROMPT", "0") .output() .map_err(|e| format!("Failed to execute git {:?}: {}", args, e))?; if !output.status.success() { @@ -687,10 +684,9 @@ fn run_git(cwd: &Path, args: &[&str]) -> Result<(), String> { /// about the exit code — the caller decides whether success or failure is /// the spec-correct outcome. fn git_push(cwd: &Path, url: &str, refname: &str) -> std::io::Result { - Command::new("git") + git_command() .args(["push", url, &format!("HEAD:{}", refname)]) .current_dir(cwd) - .env("GIT_TERMINAL_PROMPT", "0") .output() }