use anyhow::{Context, Result};
use colored::*;
use std::path::PathBuf;
#[derive(Debug, clap::Args)]
pub struct InstallHookArgs {
#[arg(long)]
pub uninstall: bool,
#[arg(long)]
pub force: bool,
#[arg(long, default_value = ".")]
pub project: PathBuf,
}
pub fn run(args: &InstallHookArgs) -> Result<()> {
if args.uninstall {
uninstall_hook(args)
} else {
install_hook(args)
}
}
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
"#;
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");
if !hooks_dir.exists() {
std::fs::create_dir_all(&hooks_dir).with_context(|| {
format!("Failed to create hooks directory: {}", hooks_dir.display())
})?;
}
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()
);
}
std::fs::write(&hook_path, HOOK_SCRIPT)
.with_context(|| format!("Failed to write hook: {}", hook_path.display()))?;
#[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(())
}
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());
}
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(())
}
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);
}
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");
let install_args = InstallHookArgs {
uninstall: false,
force: false,
project: dir.path().to_path_buf(),
};
run(&install_args).unwrap();
assert!(hook_path.exists());
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() {
assert!(HOOK_SCRIPT.contains("--sources \"$file\""));
assert!(HOOK_SCRIPT.contains("git diff --cached --name-only"));
}
#[test]
fn test_hook_script_blocked_flag_exit_code() {
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() {
assert!(HOOK_SCRIPT.contains("while IFS= read -r file; do"));
assert!(HOOK_SCRIPT.contains("done <<EOF"));
}
}