Skip to main content

auths_id/storage/registry/
hooks.rs

1//! Git hooks for cache invalidation.
2//!
3//! Installs Git hooks (post-merge, post-checkout, post-rewrite) in the auths
4//! repository that touch a `.stale` sentinel file. This triggers cache
5//! invalidation on the next read operation.
6
7#[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/// Errors that can occur during hook installation.
16#[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/// Hook types that invalidate the cache.
43#[cfg(not(windows))]
44const CACHE_HOOKS: &[&str] = &["post-merge", "post-checkout", "post-rewrite"];
45
46/// Marker comment to identify the linearity enforcement hook.
47#[cfg(not(windows))]
48const LINEARITY_MARKER: &str = "# auths-linearity-enforcement";
49
50/// Marker comment to identify our hooks.
51#[cfg(not(windows))]
52const HOOK_MARKER: &str = "# auths-cache-invalidation";
53
54/// The shell script snippet that touches the sentinel file.
55#[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
68/// Install cache invalidation hooks in a Git repository.
69///
70/// Appends to existing hooks (doesn't overwrite user scripts).
71/// Skips gracefully on Windows (hooks are optional for cache).
72///
73/// # Arguments
74/// * `repo_path` - Path to the Git repository (e.g., `~/.auths`)
75/// * `cache_dir` - Path to the cache directory (e.g., `~/.auths/.cache`)
76pub 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/// Install a single hook, appending to existing content.
102#[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    // Skip if already installed
113    if existing_content.contains(HOOK_MARKER) {
114        return Ok(());
115    }
116
117    // Build new content
118    let new_content = if existing_content.is_empty() {
119        // New hook - add shebang
120        format!("#!/bin/sh\n{}", snippet)
121    } else if existing_content.starts_with("#!") {
122        // Append to existing hook
123        format!("{}\n{}", existing_content.trim_end(), snippet)
124    } else {
125        // Existing content without shebang - add one
126        format!("#!/bin/sh\n{}\n{}", existing_content.trim_end(), snippet)
127    };
128
129    fs::write(&hook_path, new_content)?;
130
131    // Make executable
132    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/// Find the .git directory for a repository.
140#[cfg(not(windows))]
141fn find_git_dir(repo_path: &Path) -> Result<std::path::PathBuf, HookError> {
142    // Check for .git directory
143    let git_dir = repo_path.join(".git");
144    if git_dir.is_dir() {
145        return Ok(git_dir);
146    }
147
148    // Check if .git is a file (worktree or submodule)
149    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    // Check if we're inside a git directory (bare repo)
162    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
169/// Uninstall cache invalidation hooks from a Git repository.
170///
171/// Removes our snippet from hooks but preserves other user content.
172pub 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                // Find and remove our snippet by filtering lines
189                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                            // Our snippet ends after the touch command
200                            if line.trim().starts_with("touch") && line.contains(".stale") {
201                                in_our_snippet = false;
202                                continue;
203                            }
204                            // Skip comment lines that are part of our snippet
205                            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                    // If only shebang left, remove the hook
215                    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/// The pre-receive hook script that rejects non-fast-forward pushes and ref
230/// deletions for Auths identity ref namespaces.
231#[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
275/// Install the linearity enforcement pre-receive hook in a Git repository.
276///
277/// This hook rejects non-fast-forward pushes and ref deletions for protected
278/// Auths ref namespaces (`refs/keri/`, `refs/auths/`, `refs/did/keri/`,
279/// `refs/auths/`). It prevents Git-level KEL rewrites that would bypass the
280/// Rust registry's application-level checks.
281///
282/// For bare repos that already have a pre-receive hook, this appends rather
283/// than overwrites. Idempotent — safe to call multiple times.
284///
285/// # Arguments
286/// * `repo_path` - Path to the Git repository (e.g., `~/.auths`)
287pub 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        // Skip if already installed
311        if existing_content.contains(LINEARITY_MARKER) {
312            return Ok(());
313        }
314
315        // The script includes its own shebang, so if the file is empty we
316        // write it directly. If there's an existing hook, append our logic.
317        let new_content = if existing_content.is_empty() {
318            LINEARITY_HOOK_SCRIPT.to_string()
319        } else {
320            // Strip our shebang line — the existing file already has one.
321            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
340/// Uninstall the linearity enforcement hook from a Git repository.
341///
342/// Removes the auths linearity section from the pre-receive hook while
343/// preserving any other content.
344pub 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        // Remove everything from the marker to "exit 0" (inclusive)
365        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        // Check hooks were created
414        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        // Create existing hook
435        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 twice
459        install_cache_hooks(repo_path, &cache_dir).unwrap();
460        install_cache_hooks(repo_path, &cache_dir).unwrap();
461
462        // Should only have one marker
463        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 then uninstall
478        install_cache_hooks(repo_path, &cache_dir).unwrap();
479        uninstall_cache_hooks(repo_path).unwrap();
480
481        // Hooks should be removed (since they only had our content)
482        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    // --- Linearity hook tests ---
490
491    #[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        // Should not have a duplicate shebang
542        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}