auths_id/storage/registry/
hooks.rs1#[cfg(not(windows))]
8use std::fs;
9#[cfg(unix)]
10use std::os::unix::fs::PermissionsExt;
11use std::path::Path;
12
13use thiserror::Error;
14
15#[derive(Debug, Error)]
17#[non_exhaustive]
18pub enum HookError {
19 #[error("IO error: {0}")]
20 Io(#[from] std::io::Error),
21
22 #[error("Not a Git repository: {0}")]
23 NotGitRepo(String),
24}
25
26impl auths_core::error::AuthsErrorInfo for HookError {
27 fn error_code(&self) -> &'static str {
28 match self {
29 Self::Io(_) => "AUTHS-E4991",
30 Self::NotGitRepo(_) => "AUTHS-E4992",
31 }
32 }
33
34 fn suggestion(&self) -> Option<&'static str> {
35 match self {
36 Self::Io(_) => Some("Check file permissions on the Git hooks directory"),
37 Self::NotGitRepo(_) => Some("Ensure the path points to a valid Git repository"),
38 }
39 }
40}
41
42#[cfg(not(windows))]
44const CACHE_HOOKS: &[&str] = &["post-merge", "post-checkout", "post-rewrite"];
45
46#[cfg(not(windows))]
48const LINEARITY_MARKER: &str = "# auths-linearity-enforcement";
49
50#[cfg(not(windows))]
52const HOOK_MARKER: &str = "# auths-cache-invalidation";
53
54#[cfg(not(windows))]
56fn cache_invalidation_snippet(cache_dir: &Path) -> String {
57 let sentinel = cache_dir.join(".stale");
58 format!(
59 r#"
60{HOOK_MARKER}
61# Touch sentinel file to invalidate cache on next read
62touch "{}" 2>/dev/null || true
63"#,
64 sentinel.display()
65 )
66}
67
68pub fn install_cache_hooks(_repo_path: &Path, _cache_dir: &Path) -> Result<(), HookError> {
77 #[cfg(windows)]
78 {
79 return Ok(());
80 }
81
82 #[cfg(not(windows))]
83 {
84 let git_dir = find_git_dir(_repo_path)?;
85 let hooks_dir = git_dir.join("hooks");
86
87 if !hooks_dir.exists() {
88 fs::create_dir_all(&hooks_dir)?;
89 }
90
91 let snippet = cache_invalidation_snippet(_cache_dir);
92
93 for hook_name in CACHE_HOOKS {
94 install_hook(&hooks_dir, hook_name, &snippet)?;
95 }
96
97 Ok(())
98 }
99}
100
101#[cfg(not(windows))]
103fn install_hook(hooks_dir: &Path, hook_name: &str, snippet: &str) -> Result<(), HookError> {
104 let hook_path = hooks_dir.join(hook_name);
105
106 let existing_content = if hook_path.exists() {
107 fs::read_to_string(&hook_path)?
108 } else {
109 String::new()
110 };
111
112 if existing_content.contains(HOOK_MARKER) {
114 return Ok(());
115 }
116
117 let new_content = if existing_content.is_empty() {
119 format!("#!/bin/sh\n{}", snippet)
121 } else if existing_content.starts_with("#!") {
122 format!("{}\n{}", existing_content.trim_end(), snippet)
124 } else {
125 format!("#!/bin/sh\n{}\n{}", existing_content.trim_end(), snippet)
127 };
128
129 fs::write(&hook_path, new_content)?;
130
131 let mut perms = fs::metadata(&hook_path)?.permissions();
133 perms.set_mode(0o755);
134 fs::set_permissions(&hook_path, perms)?;
135
136 Ok(())
137}
138
139#[cfg(not(windows))]
141fn find_git_dir(repo_path: &Path) -> Result<std::path::PathBuf, HookError> {
142 let git_dir = repo_path.join(".git");
144 if git_dir.is_dir() {
145 return Ok(git_dir);
146 }
147
148 if git_dir.is_file()
150 && let Ok(content) = fs::read_to_string(&git_dir)
151 && let Some(path) = content.strip_prefix("gitdir: ")
152 {
153 let linked_path = std::path::PathBuf::from(path.trim());
154 if linked_path.is_absolute() {
155 return Ok(linked_path);
156 } else {
157 return Ok(repo_path.join(linked_path));
158 }
159 }
160
161 if repo_path.join("HEAD").exists() && repo_path.join("config").exists() {
163 return Ok(repo_path.to_path_buf());
164 }
165
166 Err(HookError::NotGitRepo(repo_path.display().to_string()))
167}
168
169pub fn uninstall_cache_hooks(_repo_path: &Path) -> Result<(), HookError> {
173 #[cfg(windows)]
174 {
175 return Ok(());
176 }
177
178 #[cfg(not(windows))]
179 {
180 let git_dir = find_git_dir(_repo_path)?;
181 let hooks_dir = git_dir.join("hooks");
182
183 for hook_name in CACHE_HOOKS {
184 let hook_path = hooks_dir.join(hook_name);
185 if hook_path.exists() {
186 let content = fs::read_to_string(&hook_path)?;
187
188 if content.contains(HOOK_MARKER) {
190 let mut in_our_snippet = false;
191 let mut kept_lines: Vec<&str> = Vec::new();
192
193 for line in content.lines() {
194 if line.contains(HOOK_MARKER) {
195 in_our_snippet = true;
196 continue;
197 }
198 if in_our_snippet {
199 if line.trim().starts_with("touch") && line.contains(".stale") {
201 in_our_snippet = false;
202 continue;
203 }
204 if line.starts_with("# Touch sentinel") {
206 continue;
207 }
208 }
209 kept_lines.push(line);
210 }
211
212 let new_content = kept_lines.join("\n");
213
214 let trimmed = new_content.trim();
216 if trimmed.is_empty() || trimmed == "#!/bin/sh" || trimmed == "#!/bin/bash" {
217 fs::remove_file(&hook_path)?;
218 } else {
219 fs::write(&hook_path, format!("{}\n", new_content))?;
220 }
221 }
222 }
223 }
224
225 Ok(())
226 }
227}
228
229#[cfg(not(windows))]
232const LINEARITY_HOOK_SCRIPT: &str = r#"#!/bin/sh
233# auths-linearity-enforcement
234# Reject non-fast-forward pushes and ref deletions for Auths identity refs.
235# Installed automatically by auths — do not edit the marked section.
236
237ZERO="0000000000000000000000000000000000000000"
238
239auths_reject() {
240 echo "*** REJECTED: $1" >&2
241 echo "***" >&2
242 echo "*** Auths identity refs are append-only. Force pushes and" >&2
243 echo "*** ref deletions are prohibited to maintain KEL integrity." >&2
244 echo "***" >&2
245 exit 1
246}
247
248auths_is_protected_ref() {
249 case "$1" in
250 refs/keri/*|refs/auths/*|refs/did/keri/*|refs/auths/*)
251 return 0
252 ;;
253 esac
254 return 1
255}
256
257while read oldrev newrev refname; do
258 if ! auths_is_protected_ref "$refname"; then
259 continue
260 fi
261 if [ "$oldrev" = "$ZERO" ]; then
262 continue
263 fi
264 if [ "$newrev" = "$ZERO" ]; then
265 auths_reject "Deleting protected ref '$refname' is not allowed."
266 fi
267 if ! git merge-base --is-ancestor "$oldrev" "$newrev" 2>/dev/null; then
268 auths_reject "Non-fast-forward push to '$refname' is not allowed."
269 fi
270done
271
272exit 0
273"#;
274
275pub fn install_linearity_hook(_repo_path: &Path) -> Result<(), HookError> {
288 #[cfg(windows)]
289 {
290 return Ok(());
291 }
292
293 #[cfg(not(windows))]
294 {
295 let git_dir = find_git_dir(_repo_path)?;
296 let hooks_dir = git_dir.join("hooks");
297
298 if !hooks_dir.exists() {
299 fs::create_dir_all(&hooks_dir)?;
300 }
301
302 let hook_path = hooks_dir.join("pre-receive");
303
304 let existing_content = if hook_path.exists() {
305 fs::read_to_string(&hook_path)?
306 } else {
307 String::new()
308 };
309
310 if existing_content.contains(LINEARITY_MARKER) {
312 return Ok(());
313 }
314
315 let new_content = if existing_content.is_empty() {
318 LINEARITY_HOOK_SCRIPT.to_string()
319 } else {
320 let without_shebang = LINEARITY_HOOK_SCRIPT
322 .strip_prefix("#!/bin/sh\n")
323 .unwrap_or(LINEARITY_HOOK_SCRIPT);
324 format!("{}\n{}", existing_content.trim_end(), without_shebang)
325 };
326
327 fs::write(&hook_path, new_content)?;
328
329 #[cfg(unix)]
330 {
331 let mut perms = fs::metadata(&hook_path)?.permissions();
332 perms.set_mode(0o755);
333 fs::set_permissions(&hook_path, perms)?;
334 }
335
336 Ok(())
337 }
338}
339
340pub fn uninstall_linearity_hook(_repo_path: &Path) -> Result<(), HookError> {
345 #[cfg(windows)]
346 {
347 return Ok(());
348 }
349
350 #[cfg(not(windows))]
351 {
352 let git_dir = find_git_dir(_repo_path)?;
353 let hook_path = git_dir.join("hooks").join("pre-receive");
354
355 if !hook_path.exists() {
356 return Ok(());
357 }
358
359 let content = fs::read_to_string(&hook_path)?;
360 if !content.contains(LINEARITY_MARKER) {
361 return Ok(());
362 }
363
364 let mut kept_lines: Vec<&str> = Vec::new();
366 let mut in_our_section = false;
367
368 for line in content.lines() {
369 if line.contains(LINEARITY_MARKER) {
370 in_our_section = true;
371 continue;
372 }
373 if in_our_section {
374 if line.trim() == "exit 0" {
375 in_our_section = false;
376 continue;
377 }
378 continue;
379 }
380 kept_lines.push(line);
381 }
382
383 let new_content = kept_lines.join("\n");
384 let trimmed = new_content.trim();
385
386 if trimmed.is_empty() || trimmed == "#!/bin/sh" || trimmed == "#!/bin/bash" {
387 fs::remove_file(&hook_path)?;
388 } else {
389 fs::write(&hook_path, format!("{}\n", new_content))?;
390 }
391
392 Ok(())
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use tempfile::TempDir;
400
401 #[test]
402 fn test_install_new_hooks() {
403 let temp = TempDir::new().unwrap();
404 let repo_path = temp.path();
405 let git_dir = repo_path.join(".git");
406 fs::create_dir(&git_dir).unwrap();
407
408 let cache_dir = repo_path.join(".cache");
409 fs::create_dir(&cache_dir).unwrap();
410
411 install_cache_hooks(repo_path, &cache_dir).unwrap();
412
413 let hooks_dir = git_dir.join("hooks");
415 for hook_name in CACHE_HOOKS {
416 let hook_path = hooks_dir.join(hook_name);
417 assert!(hook_path.exists(), "Hook {} should exist", hook_name);
418
419 let content = fs::read_to_string(&hook_path).unwrap();
420 assert!(content.contains("#!/bin/sh"));
421 assert!(content.contains(HOOK_MARKER));
422 assert!(content.contains(".stale"));
423 }
424 }
425
426 #[test]
427 fn test_install_appends_to_existing() {
428 let temp = TempDir::new().unwrap();
429 let repo_path = temp.path();
430 let git_dir = repo_path.join(".git");
431 let hooks_dir = git_dir.join("hooks");
432 fs::create_dir_all(&hooks_dir).unwrap();
433
434 let existing_content = "#!/bin/bash\necho 'existing hook'\n";
436 fs::write(hooks_dir.join("post-merge"), existing_content).unwrap();
437
438 let cache_dir = repo_path.join(".cache");
439 install_cache_hooks(repo_path, &cache_dir).unwrap();
440
441 let content = fs::read_to_string(hooks_dir.join("post-merge")).unwrap();
442 assert!(
443 content.contains("existing hook"),
444 "Should preserve existing content"
445 );
446 assert!(content.contains(HOOK_MARKER), "Should add our marker");
447 }
448
449 #[test]
450 fn test_install_idempotent() {
451 let temp = TempDir::new().unwrap();
452 let repo_path = temp.path();
453 let git_dir = repo_path.join(".git");
454 fs::create_dir(&git_dir).unwrap();
455
456 let cache_dir = repo_path.join(".cache");
457
458 install_cache_hooks(repo_path, &cache_dir).unwrap();
460 install_cache_hooks(repo_path, &cache_dir).unwrap();
461
462 let content = fs::read_to_string(git_dir.join("hooks").join("post-merge")).unwrap();
464 let marker_count = content.matches(HOOK_MARKER).count();
465 assert_eq!(marker_count, 1, "Should have exactly one marker");
466 }
467
468 #[test]
469 fn test_uninstall_hooks() {
470 let temp = TempDir::new().unwrap();
471 let repo_path = temp.path();
472 let git_dir = repo_path.join(".git");
473 fs::create_dir(&git_dir).unwrap();
474
475 let cache_dir = repo_path.join(".cache");
476
477 install_cache_hooks(repo_path, &cache_dir).unwrap();
479 uninstall_cache_hooks(repo_path).unwrap();
480
481 let hooks_dir = git_dir.join("hooks");
483 for hook_name in CACHE_HOOKS {
484 let hook_path = hooks_dir.join(hook_name);
485 assert!(!hook_path.exists(), "Hook {} should be removed", hook_name);
486 }
487 }
488
489 #[test]
492 fn test_install_linearity_hook_new() {
493 let temp = TempDir::new().unwrap();
494 let repo_path = temp.path();
495 let git_dir = repo_path.join(".git");
496 fs::create_dir(&git_dir).unwrap();
497
498 install_linearity_hook(repo_path).unwrap();
499
500 let hook_path = git_dir.join("hooks").join("pre-receive");
501 assert!(hook_path.exists(), "pre-receive hook should exist");
502
503 let content = fs::read_to_string(&hook_path).unwrap();
504 assert!(content.contains("#!/bin/sh"));
505 assert!(content.contains(LINEARITY_MARKER));
506 assert!(content.contains("auths_is_protected_ref"));
507 assert!(content.contains("merge-base --is-ancestor"));
508 }
509
510 #[test]
511 fn test_install_linearity_hook_idempotent() {
512 let temp = TempDir::new().unwrap();
513 let repo_path = temp.path();
514 let git_dir = repo_path.join(".git");
515 fs::create_dir(&git_dir).unwrap();
516
517 install_linearity_hook(repo_path).unwrap();
518 install_linearity_hook(repo_path).unwrap();
519
520 let content = fs::read_to_string(git_dir.join("hooks").join("pre-receive")).unwrap();
521 let marker_count = content.matches(LINEARITY_MARKER).count();
522 assert_eq!(marker_count, 1, "Should have exactly one marker");
523 }
524
525 #[test]
526 fn test_install_linearity_hook_appends_to_existing() {
527 let temp = TempDir::new().unwrap();
528 let repo_path = temp.path();
529 let git_dir = repo_path.join(".git");
530 let hooks_dir = git_dir.join("hooks");
531 fs::create_dir_all(&hooks_dir).unwrap();
532
533 let existing = "#!/bin/bash\necho 'existing pre-receive'\n";
534 fs::write(hooks_dir.join("pre-receive"), existing).unwrap();
535
536 install_linearity_hook(repo_path).unwrap();
537
538 let content = fs::read_to_string(hooks_dir.join("pre-receive")).unwrap();
539 assert!(content.contains("existing pre-receive"));
540 assert!(content.contains(LINEARITY_MARKER));
541 assert_eq!(content.matches("#!/bin/").count(), 1);
543 }
544
545 #[test]
546 fn test_uninstall_linearity_hook() {
547 let temp = TempDir::new().unwrap();
548 let repo_path = temp.path();
549 let git_dir = repo_path.join(".git");
550 fs::create_dir(&git_dir).unwrap();
551
552 install_linearity_hook(repo_path).unwrap();
553 uninstall_linearity_hook(repo_path).unwrap();
554
555 let hook_path = git_dir.join("hooks").join("pre-receive");
556 assert!(!hook_path.exists(), "pre-receive should be removed");
557 }
558
559 #[test]
560 fn test_uninstall_linearity_hook_preserves_other_content() {
561 let temp = TempDir::new().unwrap();
562 let repo_path = temp.path();
563 let git_dir = repo_path.join(".git");
564 let hooks_dir = git_dir.join("hooks");
565 fs::create_dir_all(&hooks_dir).unwrap();
566
567 let existing = "#!/bin/bash\necho 'keep me'\n";
568 fs::write(hooks_dir.join("pre-receive"), existing).unwrap();
569
570 install_linearity_hook(repo_path).unwrap();
571 uninstall_linearity_hook(repo_path).unwrap();
572
573 let hook_path = hooks_dir.join("pre-receive");
574 assert!(hook_path.exists(), "pre-receive should still exist");
575 let content = fs::read_to_string(&hook_path).unwrap();
576 assert!(content.contains("keep me"));
577 assert!(!content.contains(LINEARITY_MARKER));
578 }
579}