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 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 fs::write(&pre_commit_path, PRE_COMMIT_SCRIPT).map_err(HookError::WriteFile)?;
69
70 #[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 let hook_path = repo.path().join(".git/hooks/pre-commit");
160 assert!(hook_path.exists());
161
162 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 HookInstaller::install(repo.path()).unwrap();
180
181 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 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 HookInstaller::install(repo.path()).unwrap();
205
206 let result = HookInstaller::uninstall(repo.path());
208 assert!(result.is_ok());
209
210 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 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 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 let error: Box<dyn std::error::Error> = Box::new(HookError::NotAGitRepository);
338 assert!(!error.to_string().is_empty());
339 }
340}