Skip to main content

cc_audit/
hooks.rs

1use std::fs;
2use std::path::Path;
3
4#[cfg(unix)]
5use std::os::unix::fs::PermissionsExt;
6use thiserror::Error;
7
8const PRE_COMMIT_SCRIPT: &str = r#"#!/bin/sh
9# cc-audit pre-commit hook
10# Automatically generated by cc-audit --init-hook
11
12# Scan staged skill files for security issues
13# You can customize the paths and options below
14
15# Find all staged .md files and skill directories
16STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
17
18# Check if there are any skill-related files
19SKILL_FILES=$(echo "$STAGED_FILES" | grep -E '(SKILL\.md|CLAUDE\.md|\.claude/|mcp\.json)')
20
21if [ -n "$SKILL_FILES" ]; then
22    echo "cc-audit: Checking staged skill files..."
23
24    # Run cc-audit on the repository root
25    if ! cc-audit .; then
26        echo ""
27        echo "cc-audit: Security issues found!"
28        echo "Please fix the issues above before committing."
29        echo ""
30        echo "To bypass this check, use: git commit --no-verify"
31        exit 1
32    fi
33
34    echo "cc-audit: All checks passed!"
35fi
36
37exit 0
38"#;
39
40pub struct HookInstaller;
41
42impl HookInstaller {
43    pub fn install(repo_path: &Path) -> Result<(), HookError> {
44        let git_dir = repo_path.join(".git");
45        if !git_dir.exists() {
46            return Err(HookError::NotAGitRepository);
47        }
48
49        let hooks_dir = git_dir.join("hooks");
50        if !hooks_dir.exists() {
51            fs::create_dir_all(&hooks_dir).map_err(HookError::CreateDir)?;
52        }
53
54        let pre_commit_path = hooks_dir.join("pre-commit");
55
56        // Check if a pre-commit hook already exists
57        if pre_commit_path.exists() {
58            let existing = fs::read_to_string(&pre_commit_path).map_err(HookError::ReadFile)?;
59
60            if existing.contains("cc-audit") {
61                return Err(HookError::AlreadyInstalled);
62            }
63
64            return Err(HookError::ExistingHook);
65        }
66
67        // Write the pre-commit script
68        fs::write(&pre_commit_path, PRE_COMMIT_SCRIPT).map_err(HookError::WriteFile)?;
69
70        // Make it executable (Unix only)
71        #[cfg(unix)]
72        {
73            let mut perms = fs::metadata(&pre_commit_path)
74                .map_err(HookError::SetPermissions)?
75                .permissions();
76            perms.set_mode(0o755);
77            fs::set_permissions(&pre_commit_path, perms).map_err(HookError::SetPermissions)?;
78        }
79
80        Ok(())
81    }
82
83    pub fn uninstall(repo_path: &Path) -> Result<(), HookError> {
84        let git_dir = repo_path.join(".git");
85        if !git_dir.exists() {
86            return Err(HookError::NotAGitRepository);
87        }
88
89        let pre_commit_path = git_dir.join("hooks").join("pre-commit");
90
91        if !pre_commit_path.exists() {
92            return Err(HookError::NotInstalled);
93        }
94
95        let existing = fs::read_to_string(&pre_commit_path).map_err(HookError::ReadFile)?;
96
97        if !existing.contains("cc-audit") {
98            return Err(HookError::NotOurHook);
99        }
100
101        fs::remove_file(&pre_commit_path).map_err(HookError::RemoveFile)?;
102
103        Ok(())
104    }
105}
106
107#[derive(Debug, Error)]
108pub enum HookError {
109    #[error("Not a git repository")]
110    NotAGitRepository,
111
112    #[error("cc-audit pre-commit hook is already installed")]
113    AlreadyInstalled,
114
115    #[error("A pre-commit hook already exists. Remove it first or add cc-audit manually")]
116    ExistingHook,
117
118    #[error("No pre-commit hook is installed")]
119    NotInstalled,
120
121    #[error("The existing pre-commit hook was not installed by cc-audit")]
122    NotOurHook,
123
124    #[error("Failed to create hooks directory: {0}")]
125    CreateDir(#[source] std::io::Error),
126
127    #[error("Failed to read hook file: {0}")]
128    ReadFile(#[source] std::io::Error),
129
130    #[error("Failed to write hook file: {0}")]
131    WriteFile(#[source] std::io::Error),
132
133    #[error("Failed to set permissions: {0}")]
134    SetPermissions(#[source] std::io::Error),
135
136    #[error("Failed to remove hook file: {0}")]
137    RemoveFile(#[source] std::io::Error),
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use tempfile::TempDir;
144
145    fn create_git_repo() -> TempDir {
146        let temp_dir = TempDir::new().unwrap();
147        let git_dir = temp_dir.path().join(".git");
148        fs::create_dir(&git_dir).unwrap();
149        temp_dir
150    }
151
152    #[test]
153    fn test_install_in_git_repo() {
154        let repo = create_git_repo();
155        let result = HookInstaller::install(repo.path());
156        assert!(result.is_ok());
157
158        // Verify the hook file exists
159        let hook_path = repo.path().join(".git/hooks/pre-commit");
160        assert!(hook_path.exists());
161
162        // Verify content
163        let content = fs::read_to_string(&hook_path).unwrap();
164        assert!(content.contains("cc-audit"));
165    }
166
167    #[test]
168    fn test_install_not_a_git_repo() {
169        let temp_dir = TempDir::new().unwrap();
170        let result = HookInstaller::install(temp_dir.path());
171        assert!(matches!(result, Err(HookError::NotAGitRepository)));
172    }
173
174    #[test]
175    fn test_install_already_installed() {
176        let repo = create_git_repo();
177
178        // First install
179        HookInstaller::install(repo.path()).unwrap();
180
181        // Second install should fail
182        let result = HookInstaller::install(repo.path());
183        assert!(matches!(result, Err(HookError::AlreadyInstalled)));
184    }
185
186    #[test]
187    fn test_install_existing_hook() {
188        let repo = create_git_repo();
189
190        // Create hooks dir and an existing hook
191        let hooks_dir = repo.path().join(".git/hooks");
192        fs::create_dir(&hooks_dir).unwrap();
193        fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho 'other hook'").unwrap();
194
195        let result = HookInstaller::install(repo.path());
196        assert!(matches!(result, Err(HookError::ExistingHook)));
197    }
198
199    #[test]
200    fn test_uninstall() {
201        let repo = create_git_repo();
202
203        // Install first
204        HookInstaller::install(repo.path()).unwrap();
205
206        // Uninstall
207        let result = HookInstaller::uninstall(repo.path());
208        assert!(result.is_ok());
209
210        // Verify hook is removed
211        let hook_path = repo.path().join(".git/hooks/pre-commit");
212        assert!(!hook_path.exists());
213    }
214
215    #[test]
216    fn test_uninstall_not_installed() {
217        let repo = create_git_repo();
218        let result = HookInstaller::uninstall(repo.path());
219        assert!(matches!(result, Err(HookError::NotInstalled)));
220    }
221
222    #[test]
223    fn test_uninstall_not_a_git_repo() {
224        let temp_dir = TempDir::new().unwrap();
225        let result = HookInstaller::uninstall(temp_dir.path());
226        assert!(matches!(result, Err(HookError::NotAGitRepository)));
227    }
228
229    #[test]
230    fn test_uninstall_not_our_hook() {
231        let repo = create_git_repo();
232
233        // Create hooks dir and a different hook
234        let hooks_dir = repo.path().join(".git/hooks");
235        fs::create_dir(&hooks_dir).unwrap();
236        fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho 'other hook'").unwrap();
237
238        let result = HookInstaller::uninstall(repo.path());
239        assert!(matches!(result, Err(HookError::NotOurHook)));
240    }
241
242    #[test]
243    #[cfg(unix)]
244    fn test_hook_is_executable() {
245        let repo = create_git_repo();
246        HookInstaller::install(repo.path()).unwrap();
247
248        let hook_path = repo.path().join(".git/hooks/pre-commit");
249        let metadata = fs::metadata(&hook_path).unwrap();
250        let permissions = metadata.permissions();
251
252        // Check if executable bit is set (0o755 = rwxr-xr-x)
253        assert_eq!(permissions.mode() & 0o111, 0o111);
254    }
255
256    #[test]
257    fn test_hook_error_display_not_a_git_repository() {
258        let error = HookError::NotAGitRepository;
259        assert_eq!(format!("{}", error), "Not a git repository");
260    }
261
262    #[test]
263    fn test_hook_error_display_already_installed() {
264        let error = HookError::AlreadyInstalled;
265        assert_eq!(
266            format!("{}", error),
267            "cc-audit pre-commit hook is already installed"
268        );
269    }
270
271    #[test]
272    fn test_hook_error_display_existing_hook() {
273        let error = HookError::ExistingHook;
274        assert_eq!(
275            format!("{}", error),
276            "A pre-commit hook already exists. Remove it first or add cc-audit manually"
277        );
278    }
279
280    #[test]
281    fn test_hook_error_display_not_installed() {
282        let error = HookError::NotInstalled;
283        assert_eq!(format!("{}", error), "No pre-commit hook is installed");
284    }
285
286    #[test]
287    fn test_hook_error_display_not_our_hook() {
288        let error = HookError::NotOurHook;
289        assert_eq!(
290            format!("{}", error),
291            "The existing pre-commit hook was not installed by cc-audit"
292        );
293    }
294
295    #[test]
296    fn test_hook_error_display_create_dir() {
297        let io_error =
298            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
299        let error = HookError::CreateDir(io_error);
300        assert!(format!("{}", error).starts_with("Failed to create hooks directory:"));
301    }
302
303    #[test]
304    fn test_hook_error_display_read_file() {
305        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
306        let error = HookError::ReadFile(io_error);
307        assert!(format!("{}", error).starts_with("Failed to read hook file:"));
308    }
309
310    #[test]
311    fn test_hook_error_display_write_file() {
312        let io_error = std::io::Error::other("disk full");
313        let error = HookError::WriteFile(io_error);
314        assert!(format!("{}", error).starts_with("Failed to write hook file:"));
315    }
316
317    #[test]
318    fn test_hook_error_display_set_permissions() {
319        let io_error = std::io::Error::new(
320            std::io::ErrorKind::PermissionDenied,
321            "operation not permitted",
322        );
323        let error = HookError::SetPermissions(io_error);
324        assert!(format!("{}", error).starts_with("Failed to set permissions:"));
325    }
326
327    #[test]
328    fn test_hook_error_display_remove_file() {
329        let io_error = std::io::Error::other("file in use");
330        let error = HookError::RemoveFile(io_error);
331        assert!(format!("{}", error).starts_with("Failed to remove hook file:"));
332    }
333
334    #[test]
335    fn test_hook_error_is_error() {
336        // Verify HookError implements std::error::Error
337        let error: Box<dyn std::error::Error> = Box::new(HookError::NotAGitRepository);
338        assert!(!error.to_string().is_empty());
339    }
340}