use std::fs;
use std::path::Path;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use thiserror::Error;
const PRE_COMMIT_SCRIPT: &str = r#"#!/bin/sh
# cc-audit pre-commit hook
# Automatically generated by cc-audit --init-hook
# Scan staged skill files for security issues
# You can customize the paths and options below
# Find all staged .md files and skill directories
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
# Check if there are any skill-related files
SKILL_FILES=$(echo "$STAGED_FILES" | grep -E '(SKILL\.md|CLAUDE\.md|\.claude/|mcp\.json)')
if [ -n "$SKILL_FILES" ]; then
echo "cc-audit: Checking staged skill files..."
# Run cc-audit on the repository root
if ! cc-audit .; then
echo ""
echo "cc-audit: Security issues found!"
echo "Please fix the issues above before committing."
echo ""
echo "To bypass this check, use: git commit --no-verify"
exit 1
fi
echo "cc-audit: All checks passed!"
fi
exit 0
"#;
pub struct HookInstaller;
impl HookInstaller {
pub fn install(repo_path: &Path) -> Result<(), HookError> {
let git_dir = repo_path.join(".git");
if !git_dir.exists() {
return Err(HookError::NotAGitRepository);
}
let hooks_dir = git_dir.join("hooks");
if !hooks_dir.exists() {
fs::create_dir_all(&hooks_dir).map_err(HookError::CreateDir)?;
}
let pre_commit_path = hooks_dir.join("pre-commit");
if pre_commit_path.exists() {
let existing = fs::read_to_string(&pre_commit_path).map_err(HookError::ReadFile)?;
if existing.contains("cc-audit") {
return Err(HookError::AlreadyInstalled);
}
return Err(HookError::ExistingHook);
}
fs::write(&pre_commit_path, PRE_COMMIT_SCRIPT).map_err(HookError::WriteFile)?;
#[cfg(unix)]
{
let mut perms = fs::metadata(&pre_commit_path)
.map_err(HookError::SetPermissions)?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&pre_commit_path, perms).map_err(HookError::SetPermissions)?;
}
Ok(())
}
pub fn uninstall(repo_path: &Path) -> Result<(), HookError> {
let git_dir = repo_path.join(".git");
if !git_dir.exists() {
return Err(HookError::NotAGitRepository);
}
let pre_commit_path = git_dir.join("hooks").join("pre-commit");
if !pre_commit_path.exists() {
return Err(HookError::NotInstalled);
}
let existing = fs::read_to_string(&pre_commit_path).map_err(HookError::ReadFile)?;
if !existing.contains("cc-audit") {
return Err(HookError::NotOurHook);
}
fs::remove_file(&pre_commit_path).map_err(HookError::RemoveFile)?;
Ok(())
}
}
#[derive(Debug, Error)]
pub enum HookError {
#[error("Not a git repository")]
NotAGitRepository,
#[error("cc-audit pre-commit hook is already installed")]
AlreadyInstalled,
#[error("A pre-commit hook already exists. Remove it first or add cc-audit manually")]
ExistingHook,
#[error("No pre-commit hook is installed")]
NotInstalled,
#[error("The existing pre-commit hook was not installed by cc-audit")]
NotOurHook,
#[error("Failed to create hooks directory: {0}")]
CreateDir(#[source] std::io::Error),
#[error("Failed to read hook file: {0}")]
ReadFile(#[source] std::io::Error),
#[error("Failed to write hook file: {0}")]
WriteFile(#[source] std::io::Error),
#[error("Failed to set permissions: {0}")]
SetPermissions(#[source] std::io::Error),
#[error("Failed to remove hook file: {0}")]
RemoveFile(#[source] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_git_repo() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let git_dir = temp_dir.path().join(".git");
fs::create_dir(&git_dir).unwrap();
temp_dir
}
#[test]
fn test_install_in_git_repo() {
let repo = create_git_repo();
let result = HookInstaller::install(repo.path());
assert!(result.is_ok());
let hook_path = repo.path().join(".git/hooks/pre-commit");
assert!(hook_path.exists());
let content = fs::read_to_string(&hook_path).unwrap();
assert!(content.contains("cc-audit"));
}
#[test]
fn test_install_not_a_git_repo() {
let temp_dir = TempDir::new().unwrap();
let result = HookInstaller::install(temp_dir.path());
assert!(matches!(result, Err(HookError::NotAGitRepository)));
}
#[test]
fn test_install_already_installed() {
let repo = create_git_repo();
HookInstaller::install(repo.path()).unwrap();
let result = HookInstaller::install(repo.path());
assert!(matches!(result, Err(HookError::AlreadyInstalled)));
}
#[test]
fn test_install_existing_hook() {
let repo = create_git_repo();
let hooks_dir = repo.path().join(".git/hooks");
fs::create_dir(&hooks_dir).unwrap();
fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho 'other hook'").unwrap();
let result = HookInstaller::install(repo.path());
assert!(matches!(result, Err(HookError::ExistingHook)));
}
#[test]
fn test_uninstall() {
let repo = create_git_repo();
HookInstaller::install(repo.path()).unwrap();
let result = HookInstaller::uninstall(repo.path());
assert!(result.is_ok());
let hook_path = repo.path().join(".git/hooks/pre-commit");
assert!(!hook_path.exists());
}
#[test]
fn test_uninstall_not_installed() {
let repo = create_git_repo();
let result = HookInstaller::uninstall(repo.path());
assert!(matches!(result, Err(HookError::NotInstalled)));
}
#[test]
fn test_uninstall_not_a_git_repo() {
let temp_dir = TempDir::new().unwrap();
let result = HookInstaller::uninstall(temp_dir.path());
assert!(matches!(result, Err(HookError::NotAGitRepository)));
}
#[test]
fn test_uninstall_not_our_hook() {
let repo = create_git_repo();
let hooks_dir = repo.path().join(".git/hooks");
fs::create_dir(&hooks_dir).unwrap();
fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho 'other hook'").unwrap();
let result = HookInstaller::uninstall(repo.path());
assert!(matches!(result, Err(HookError::NotOurHook)));
}
#[test]
#[cfg(unix)]
fn test_hook_is_executable() {
let repo = create_git_repo();
HookInstaller::install(repo.path()).unwrap();
let hook_path = repo.path().join(".git/hooks/pre-commit");
let metadata = fs::metadata(&hook_path).unwrap();
let permissions = metadata.permissions();
assert_eq!(permissions.mode() & 0o111, 0o111);
}
#[test]
fn test_hook_error_display_not_a_git_repository() {
let error = HookError::NotAGitRepository;
assert_eq!(format!("{}", error), "Not a git repository");
}
#[test]
fn test_hook_error_display_already_installed() {
let error = HookError::AlreadyInstalled;
assert_eq!(
format!("{}", error),
"cc-audit pre-commit hook is already installed"
);
}
#[test]
fn test_hook_error_display_existing_hook() {
let error = HookError::ExistingHook;
assert_eq!(
format!("{}", error),
"A pre-commit hook already exists. Remove it first or add cc-audit manually"
);
}
#[test]
fn test_hook_error_display_not_installed() {
let error = HookError::NotInstalled;
assert_eq!(format!("{}", error), "No pre-commit hook is installed");
}
#[test]
fn test_hook_error_display_not_our_hook() {
let error = HookError::NotOurHook;
assert_eq!(
format!("{}", error),
"The existing pre-commit hook was not installed by cc-audit"
);
}
#[test]
fn test_hook_error_display_create_dir() {
let io_error =
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
let error = HookError::CreateDir(io_error);
assert!(format!("{}", error).starts_with("Failed to create hooks directory:"));
}
#[test]
fn test_hook_error_display_read_file() {
let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let error = HookError::ReadFile(io_error);
assert!(format!("{}", error).starts_with("Failed to read hook file:"));
}
#[test]
fn test_hook_error_display_write_file() {
let io_error = std::io::Error::other("disk full");
let error = HookError::WriteFile(io_error);
assert!(format!("{}", error).starts_with("Failed to write hook file:"));
}
#[test]
fn test_hook_error_display_set_permissions() {
let io_error = std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"operation not permitted",
);
let error = HookError::SetPermissions(io_error);
assert!(format!("{}", error).starts_with("Failed to set permissions:"));
}
#[test]
fn test_hook_error_display_remove_file() {
let io_error = std::io::Error::other("file in use");
let error = HookError::RemoveFile(io_error);
assert!(format!("{}", error).starts_with("Failed to remove hook file:"));
}
#[test]
fn test_hook_error_is_error() {
let error: Box<dyn std::error::Error> = Box::new(HookError::NotAGitRepository);
assert!(!error.to_string().is_empty());
}
}