forge-guard 0.3.3

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! `forge-guard install-hook` — install/uninstall git pre-commit hook for Solidity auditing.
//!
//! Installs a pre-commit hook that runs `forge-guard audit --quick` on staged
//! `.sol` files before each commit. Blocks commits with HIGH/CRITICAL findings.

use anyhow::{Context, Result};
use colored::*;
use std::path::PathBuf;

/// Arguments for the install-hook command.
#[derive(Debug, clap::Args)]
pub struct InstallHookArgs {
    /// Uninstall the pre-commit hook instead of installing it
    #[arg(long)]
    pub uninstall: bool,

    /// Force install even if a hook already exists
    #[arg(long)]
    pub force: bool,

    /// Project root path
    #[arg(long, default_value = ".")]
    pub project: PathBuf,
}

/// Run the install-hook command.
pub fn run(args: &InstallHookArgs) -> Result<()> {
    if args.uninstall {
        uninstall_hook(args)
    } else {
        install_hook(args)
    }
}

/// Hook script content that runs forge-guard audit on staged .sol files.
/// Audits only the staged file (`--sources "$file"`) for minimal latency and
/// respects .gitignore by using `git diff --cached`.
const HOOK_SCRIPT: &str = r#"#!/bin/sh
# forge-guard pre-commit hook — audit staged Solidity files
# Installed by: forge-guard install-hook
# Uninstall with: forge-guard install-hook --uninstall

if [ -n "$FORGE_GUARD_SKIP_HOOK" ]; then
    echo "🔓 forge-guard hook skipped (FORGE_GUARD_SKIP_HOOK is set)"
    exit 0
fi

STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.sol$' || true)

if [ -z "$STAGED_FILES" ]; then
    exit 0
fi

echo "🔍 forge-guard: auditing staged Solidity files..."
BLOCKED=0
while IFS= read -r file; do
    [ -z "$file" ] && continue
    if [ -f "$file" ]; then
        # Audit only the staged file for minimal latency
        if forge-guard audit --quick --json --sources "$file" 2>/dev/null | grep -q '"severity":"critical"\|"severity":"high"'; then
            echo "❌ forge-guard: $file has HIGH/CRITICAL findings. Commit blocked."
            echo "   To bypass: FORGE_GUARD_SKIP_HOOK=1 git commit"
            BLOCKED=1
        fi
    fi
done <<EOF
$STAGED_FILES
EOF

if [ "$BLOCKED" -ne 0 ]; then
    exit 1
fi

echo "✅ forge-guard: all staged .sol files pass"
exit 0
"#;

/// Install the pre-commit hook.
fn install_hook(args: &InstallHookArgs) -> Result<()> {
    let git_dir = find_git_dir(&args.project)?;
    let hooks_dir = git_dir.join("hooks");
    let hook_path = hooks_dir.join("pre-commit");

    // Create hooks directory if needed
    if !hooks_dir.exists() {
        std::fs::create_dir_all(&hooks_dir).with_context(|| {
            format!("Failed to create hooks directory: {}", hooks_dir.display())
        })?;
    }

    // Check if hook already exists
    if hook_path.exists() && !args.force {
        anyhow::bail!(
            "Pre-commit hook already exists at {}\n  Use --force to overwrite, or --uninstall to remove it",
            hook_path.display()
        );
    }

    // Write hook script
    std::fs::write(&hook_path, HOOK_SCRIPT)
        .with_context(|| format!("Failed to write hook: {}", hook_path.display()))?;

    // Make executable
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(&hook_path)?.permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(&hook_path, perms)?;
    }

    eprintln!(
        "{} Pre-commit hook installed: {}",
        "".green().bold(),
        hook_path.display()
    );
    eprintln!("   Runs forge-guard audit --quick on staged .sol files");
    eprintln!("   Blocks commits with HIGH/CRITICAL findings");
    eprintln!("   Bypass with: FORGE_GUARD_SKIP_HOOK=1 git commit");

    Ok(())
}

/// Uninstall the pre-commit hook.
fn uninstall_hook(args: &InstallHookArgs) -> Result<()> {
    let git_dir = find_git_dir(&args.project)?;
    let hook_path = git_dir.join("hooks").join("pre-commit");

    if !hook_path.exists() {
        anyhow::bail!("No pre-commit hook found at {}", hook_path.display());
    }

    // Verify it's a forge-guard hook before deleting (check for our marker)
    let content = std::fs::read_to_string(&hook_path)?;
    if !content.contains("forge-guard pre-commit hook") {
        anyhow::bail!(
            "Hook at {} doesn't appear to be a forge-guard hook — refusing to remove",
            hook_path.display()
        );
    }

    std::fs::remove_file(&hook_path)
        .with_context(|| format!("Failed to remove hook: {}", hook_path.display()))?;

    eprintln!(
        "{} Pre-commit hook removed: {}",
        "🗑️".green().bold(),
        hook_path.display()
    );

    Ok(())
}

/// Find the .git directory for the project.
fn find_git_dir(project_root: &std::path::Path) -> Result<PathBuf> {
    let git_dir = project_root.join(".git");
    if git_dir.is_dir() {
        return Ok(git_dir);
    }

    // Check if .git is a file (common in worktrees/submodules)
    if git_dir.is_file() {
        let content = std::fs::read_to_string(&git_dir)?;
        if let Some(path) = content.strip_prefix("gitdir: ") {
            let actual_path = project_root.join(path.trim());
            if actual_path.is_dir() {
                return Ok(actual_path);
            }
        }
    }

    anyhow::bail!(
        "No .git directory found in {}. Are you in a git repository?",
        project_root.display()
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    fn create_temp_git_repo() -> tempfile::TempDir {
        let dir = tempfile::tempdir().unwrap();
        let git_dir = dir.path().join(".git");
        fs::create_dir_all(git_dir.join("hooks")).unwrap();
        dir
    }

    #[test]
    fn test_install_hook_creates_file() {
        let dir = create_temp_git_repo();
        let args = InstallHookArgs {
            uninstall: false,
            force: false,
            project: dir.path().to_path_buf(),
        };

        run(&args).unwrap();

        let hook_path = dir.path().join(".git").join("hooks").join("pre-commit");
        assert!(hook_path.exists(), "Hook file should exist");

        let content = fs::read_to_string(&hook_path).unwrap();
        assert!(content.contains("forge-guard pre-commit hook"));
        assert!(content.contains("forge-guard audit"));
    }

    #[test]
    fn test_install_hook_executable() {
        let dir = create_temp_git_repo();
        let args = InstallHookArgs {
            uninstall: false,
            force: false,
            project: dir.path().to_path_buf(),
        };

        run(&args).unwrap();

        let hook_path = dir.path().join(".git").join("hooks").join("pre-commit");
        let metadata = fs::metadata(&hook_path).unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = metadata.permissions().mode();
            assert!(mode & 0o111 != 0, "Hook should be executable");
        }
    }

    #[test]
    fn test_install_hook_existing_without_force_fails() {
        let dir = create_temp_git_repo();
        let hook_path = dir.path().join(".git").join("hooks").join("pre-commit");
        fs::write(&hook_path, "existing hook").unwrap();

        let args = InstallHookArgs {
            uninstall: false,
            force: false,
            project: dir.path().to_path_buf(),
        };

        let result = run(&args);
        assert!(
            result.is_err(),
            "Should fail when hook exists without --force"
        );
        assert!(result.unwrap_err().to_string().contains("already exists"));
    }

    #[test]
    fn test_install_hook_with_force_overwrites() {
        let dir = create_temp_git_repo();
        let hook_path = dir.path().join(".git").join("hooks").join("pre-commit");
        fs::write(&hook_path, "old hook").unwrap();

        let args = InstallHookArgs {
            uninstall: false,
            force: true,
            project: dir.path().to_path_buf(),
        };

        run(&args).unwrap();
        let content = fs::read_to_string(&hook_path).unwrap();
        assert!(content.contains("forge-guard pre-commit hook"));
    }

    #[test]
    fn test_uninstall_hook_removes_file() {
        let dir = create_temp_git_repo();
        let hook_path = dir.path().join(".git").join("hooks").join("pre-commit");

        // First install
        let install_args = InstallHookArgs {
            uninstall: false,
            force: false,
            project: dir.path().to_path_buf(),
        };
        run(&install_args).unwrap();
        assert!(hook_path.exists());

        // Then uninstall
        let uninstall_args = InstallHookArgs {
            uninstall: true,
            force: false,
            project: dir.path().to_path_buf(),
        };
        run(&uninstall_args).unwrap();
        assert!(!hook_path.exists(), "Hook should be removed");
    }

    #[test]
    fn test_uninstall_hook_no_hook_fails() {
        let dir = create_temp_git_repo();
        let args = InstallHookArgs {
            uninstall: true,
            force: false,
            project: dir.path().to_path_buf(),
        };

        let result = run(&args);
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("No pre-commit hook found"));
    }

    #[test]
    fn test_uninstall_non_forge_guard_hook_fails() {
        let dir = create_temp_git_repo();
        let hook_path = dir.path().join(".git").join("hooks").join("pre-commit");
        fs::write(&hook_path, "#!/bin/sh\necho \"custom hook\"").unwrap();

        let args = InstallHookArgs {
            uninstall: true,
            force: false,
            project: dir.path().to_path_buf(),
        };

        let result = run(&args);
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("doesn't appear to be a forge-guard hook"));
    }

    #[test]
    fn test_find_git_dir_nonexistent_fails() {
        let dir = tempfile::tempdir().unwrap();
        let result = find_git_dir(&dir.path().to_path_buf());
        assert!(result.is_err());
    }

    #[test]
    fn test_hook_script_contains_bypass() {
        assert!(HOOK_SCRIPT.contains("FORGE_GUARD_SKIP_HOOK"));
    }

    #[test]
    fn test_hook_script_filters_sol_files() {
        assert!(HOOK_SCRIPT.contains(r"\.sol$"));
    }

    #[test]
    fn test_hook_script_audits_staged_file_only() {
        // The hook should pass the staged file to audit for minimal latency
        assert!(HOOK_SCRIPT.contains("--sources \"$file\""));
        assert!(HOOK_SCRIPT.contains("git diff --cached --name-only"));
    }

    #[test]
    fn test_hook_script_blocked_flag_exit_code() {
        // Uses a BLOCKED flag so exit code propagates out of the loop correctly
        assert!(HOOK_SCRIPT.contains("BLOCKED=0"));
        assert!(HOOK_SCRIPT.contains("BLOCKED=1"));
        assert!(HOOK_SCRIPT.contains("if [ \"$BLOCKED\" -ne 0 ]; then"));
    }

    #[test]
    fn test_hook_script_space_safe_loop() {
        // Uses `while IFS= read -r` so staged filenames with spaces don't break
        assert!(HOOK_SCRIPT.contains("while IFS= read -r file; do"));
        assert!(HOOK_SCRIPT.contains("done <<EOF"));
    }
}