1use std::path::{Path, PathBuf};
26
27use crate::ports::git_config::{GitConfigError, GitConfigProvider};
28
29pub const TRAILERS_FILE: &str = "commit-trailers";
31pub const ROOT_PIN_FILE: &str = "root-pin";
33pub const HOOKS_DIR: &str = "githooks";
35
36pub const PREPARE_COMMIT_MSG_HOOK: &str = r#"#!/bin/sh
50# auths prepare-commit-msg hook v2 — managed by `auths init`, checked by
51# `auths doctor`. Injects the Auths-Id / Auths-Device trailers so `auths verify`
52# can replay the signer's KEL, and repairs a missing/untracked .auths/roots trust
53# pin (the committed trust declaration teammates and CI inherit).
54# Chains to the repo's own hook when one exists.
55
56MSG_FILE="$1"
57AUTHS_HOME="${AUTHS_REPO:-$HOME/.auths}"
58TRAILERS="$AUTHS_HOME/commit-trailers"
59
60if [ -f "$TRAILERS" ]; then
61 TOPLEVEL="$(git rev-parse --show-toplevel 2>/dev/null)"
62 if [ -n "$TOPLEVEL" ] && [ -f "$AUTHS_HOME/root-pin" ]; then
63 PIN="$TOPLEVEL/.auths/roots"
64 [ -e "$PIN" ] || {
65 mkdir -p "$TOPLEVEL/.auths" && cp "$AUTHS_HOME/root-pin" "$PIN"
66 }
67 # Stage only when git isn't tracking it yet. This lands in the NEXT commit:
68 # git snapshotted the index before this hook ran.
69 if [ -e "$PIN" ] && ! git ls-files --error-unmatch -- "$PIN" >/dev/null 2>&1; then
70 git add -- "$PIN" >/dev/null 2>&1 &&
71 echo "auths: staged .auths/roots — your trust pin lands in your next commit" >&2
72 fi
73 fi
74 while IFS= read -r trailer; do
75 case "$trailer" in
76 '' | '#'*) ;;
77 *) git interpret-trailers --in-place --if-exists replace --trailer "$trailer" "$MSG_FILE" ;;
78 esac
79 done <"$TRAILERS"
80fi
81
82REPO_HOOK="$(git rev-parse --git-dir 2>/dev/null)/hooks/prepare-commit-msg"
83if [ -x "$REPO_HOOK" ]; then
84 exec "$REPO_HOOK" "$@"
85fi
86exit 0
87"#;
88
89pub const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
100# auths pre-push hook v1 — managed by `auths init`, checked by `auths doctor`.
101# Chains to the repo's own pre-push hook: core.hooksPath replaces .git/hooks,
102# so without this the managed hooks would disable repo-local ones.
103
104REPO_HOOK="$(git rev-parse --git-dir 2>/dev/null)/hooks/pre-push"
105if [ -x "$REPO_HOOK" ]; then
106 exec "$REPO_HOOK" "$@"
107fi
108exit 0
109"#;
110
111#[derive(Debug, thiserror::Error)]
113pub enum CommitHookError {
114 #[error("could not write {path}: {source}")]
116 Write {
117 path: PathBuf,
119 source: std::io::Error,
121 },
122 #[error("could not set core.hooksPath: {0}")]
124 GitConfig(#[from] GitConfigError),
125 #[error("hooks path is not valid UTF-8: {0}")]
127 NonUtf8Path(PathBuf),
128 #[error("could not resolve the local signer: {0}")]
130 Signer(String),
131
132 #[error(
134 "core.hooksPath is already set to {existing} (a hook manager like husky, pre-commit or prek owns it). Auths will not overwrite it: core.hooksPath replaces .git/hooks entirely, so doing so would silently disable every hook it installed. Copy the auths prepare-commit-msg and pre-push hooks from {wanted} into {existing}, or re-run with --git-scope skip and wire them yourself."
135 )]
136 HooksPathOccupied {
137 existing: String,
139 wanted: String,
141 },
142}
143
144fn write_file(path: &Path, content: &str) -> Result<(), CommitHookError> {
145 if let Some(parent) = path.parent() {
146 std::fs::create_dir_all(parent).map_err(|source| CommitHookError::Write {
147 path: parent.to_path_buf(),
148 source,
149 })?;
150 }
151 std::fs::write(path, content).map_err(|source| CommitHookError::Write {
152 path: path.to_path_buf(),
153 source,
154 })
155}
156
157fn write_executable(path: &Path, content: &str) -> Result<(), CommitHookError> {
159 write_file(path, content)?;
160 #[cfg(unix)]
161 {
162 use std::os::unix::fs::PermissionsExt;
163 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).map_err(
164 |source| CommitHookError::Write {
165 path: path.to_path_buf(),
166 source,
167 },
168 )?;
169 }
170 Ok(())
171}
172
173pub fn hook_path(auths_home: &Path) -> PathBuf {
183 auths_home.join(HOOKS_DIR).join("prepare-commit-msg")
184}
185
186pub fn pre_push_hook_path(auths_home: &Path) -> PathBuf {
196 auths_home.join(HOOKS_DIR).join("pre-push")
197}
198
199pub fn hook_is_current(auths_home: &Path) -> bool {
213 let matches = |path: PathBuf, expected: &str| {
214 std::fs::read_to_string(path)
215 .map(|content| content == expected)
216 .unwrap_or(false)
217 };
218 matches(hook_path(auths_home), PREPARE_COMMIT_MSG_HOOK)
219 && matches(pre_push_hook_path(auths_home), PRE_PUSH_HOOK)
220}
221
222pub fn install_commit_hooks(
239 auths_home: &Path,
240 root_did: &str,
241 device_did: &str,
242) -> Result<PathBuf, CommitHookError> {
243 write_executable(&hook_path(auths_home), PREPARE_COMMIT_MSG_HOOK)?;
244 write_executable(&pre_push_hook_path(auths_home), PRE_PUSH_HOOK)?;
245 write_file(
246 &auths_home.join(TRAILERS_FILE),
247 &format!("Auths-Id: {root_did}\nAuths-Device: {device_did}\n"),
248 )?;
249 write_file(
250 &auths_home.join(ROOT_PIN_FILE),
251 &format!("# Pinned by auths init — the trusted root for this identity.\n{root_did}\n"),
252 )?;
253 Ok(auths_home.join(HOOKS_DIR))
254}
255
256pub fn refresh_commit_trailers(
274 ctx: &crate::context::AuthsContext,
275 auths_home: &Path,
276) -> Result<(), CommitHookError> {
277 let signer = crate::domains::identity::local::resolve_local_signer(ctx)
278 .map_err(|e| CommitHookError::Signer(e.to_string()))?;
279 let mut content = format!(
280 "Auths-Id: {}\nAuths-Device: {}\n",
281 signer.root_did, signer.signer_did
282 );
283 if let Some(seq) = signer.anchor_seq {
284 content.push_str(&format!("{}\n", auths_verifier::anchor_seq_trailer(seq)));
285 }
286 write_file(&auths_home.join(TRAILERS_FILE), &content)?;
287 write_file(
288 &auths_home.join(ROOT_PIN_FILE),
289 &format!(
290 "# Pinned by auths init — the trusted root for this identity.\n{}\n",
291 signer.root_did
292 ),
293 )
294}
295
296pub fn enable_commit_trailers(
313 auths_home: &Path,
314 root_did: &str,
315 device_did: &str,
316 git_config: &dyn GitConfigProvider,
317) -> Result<(), CommitHookError> {
318 let hooks_dir = install_commit_hooks(auths_home, root_did, device_did)?;
319 let hooks_dir_str = hooks_dir
320 .to_str()
321 .ok_or_else(|| CommitHookError::NonUtf8Path(hooks_dir.clone()))?;
322
323 if let Some(existing) = git_config.get("core.hooksPath")?
329 && existing != hooks_dir_str
330 {
331 return Err(CommitHookError::HooksPathOccupied {
332 existing,
333 wanted: hooks_dir_str.to_string(),
334 });
335 }
336
337 git_config.set("core.hooksPath", hooks_dir_str)?;
338 Ok(())
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn install_writes_hook_and_data_files() {
347 let tmp = tempfile::TempDir::new().expect("temp dir");
348 let home = tmp.path();
349 let hooks_dir =
350 install_commit_hooks(home, "did:keri:Eroot", "did:keri:Edevice").expect("install");
351 assert_eq!(hooks_dir, home.join(HOOKS_DIR));
352 assert!(hook_is_current(home));
353 let trailers = std::fs::read_to_string(home.join(TRAILERS_FILE)).expect("trailers");
354 assert_eq!(
355 trailers,
356 "Auths-Id: did:keri:Eroot\nAuths-Device: did:keri:Edevice\n"
357 );
358 let pin = std::fs::read_to_string(home.join(ROOT_PIN_FILE)).expect("pin");
359 assert!(pin.lines().any(|l| l == "did:keri:Eroot"));
360 }
361
362 #[test]
363 fn stale_hook_is_not_current_and_reinstall_replaces_it() {
364 let tmp = tempfile::TempDir::new().expect("temp dir");
365 let home = tmp.path();
366 install_commit_hooks(home, "did:keri:Eroot", "did:keri:Edevice").expect("install");
367 std::fs::write(hook_path(home), "#!/bin/sh\n# old version\n").expect("overwrite");
368 assert!(!hook_is_current(home));
369 install_commit_hooks(home, "did:keri:Eroot", "did:keri:Edevice").expect("reinstall");
370 assert!(hook_is_current(home));
371 }
372
373 #[test]
378 fn hook_repairs_pin_on_tracked_ness_not_existence() {
379 assert!(
380 PREPARE_COMMIT_MSG_HOOK.contains("ls-files --error-unmatch"),
381 "pin repair must test tracked-ness, not mere existence"
382 );
383 assert!(
384 !PREPARE_COMMIT_MSG_HOOK.contains("[ ! -e \"$TOPLEVEL/.auths/roots\" ]"),
385 "the v1 existence guard defeated the mechanism it guarded"
386 );
387 }
388
389 #[test]
392 fn hook_does_not_claim_the_pin_lands_in_this_commit() {
393 assert!(
394 !PREPARE_COMMIT_MSG_HOOK.contains("committed with this commit"),
395 "false: a git add here lands in the next commit, not this one"
396 );
397 assert!(
398 PREPARE_COMMIT_MSG_HOOK.contains("next commit"),
399 "the hook must tell the truth about when the pin lands"
400 );
401 }
402
403 #[cfg(unix)]
404 #[test]
405 fn hook_is_executable() {
406 use std::os::unix::fs::PermissionsExt;
407 let tmp = tempfile::TempDir::new().expect("temp dir");
408 install_commit_hooks(tmp.path(), "did:keri:Eroot", "did:keri:Edevice").expect("install");
409 for path in [hook_path(tmp.path()), pre_push_hook_path(tmp.path())] {
410 let mode = std::fs::metadata(&path)
411 .expect("metadata")
412 .permissions()
413 .mode();
414 assert_eq!(mode & 0o111, 0o111, "{} must be executable", path.display());
415 }
416 }
417
418 #[test]
419 fn install_writes_the_pre_push_hook() {
420 let tmp = tempfile::TempDir::new().expect("temp dir");
421 install_commit_hooks(tmp.path(), "did:keri:Eroot", "did:keri:Edevice").expect("install");
422 let hook = std::fs::read_to_string(pre_push_hook_path(tmp.path())).expect("pre-push");
423 assert_eq!(hook, PRE_PUSH_HOOK);
424 assert!(hook_is_current(tmp.path()));
425 }
426
427 #[test]
428 fn stale_pre_push_is_detected_by_hook_is_current() {
429 let tmp = tempfile::TempDir::new().expect("temp dir");
430 install_commit_hooks(tmp.path(), "did:keri:Eroot", "did:keri:Edevice").expect("install");
431 std::fs::write(pre_push_hook_path(tmp.path()), "#!/bin/sh\n# old\n").expect("overwrite");
432 assert!(
433 !hook_is_current(tmp.path()),
434 "a stale pre-push means the KEL never reaches the remote; doctor must see it"
435 );
436 }
437
438 #[test]
442 fn managed_hooks_chain_to_the_repos_own_hook() {
443 for (name, hook) in [
444 ("prepare-commit-msg", PREPARE_COMMIT_MSG_HOOK),
445 ("pre-push", PRE_PUSH_HOOK),
446 ] {
447 assert!(
448 hook.contains(&format!("hooks/{name}")) && hook.contains("exec \"$REPO_HOOK\""),
449 "{name} must chain to the repo's own hook"
450 );
451 }
452 }
453
454 #[test]
459 fn enable_refuses_to_clobber_a_hook_managers_hooks_path() {
460 use crate::testing::fakes::FakeGitConfigProvider;
461
462 let tmp = tempfile::TempDir::new().expect("temp dir");
463 let git_config = FakeGitConfigProvider::new();
464 git_config
465 .set("core.hooksPath", "/repo/.husky")
466 .expect("pre-existing hook manager");
467
468 let err = enable_commit_trailers(
469 tmp.path(),
470 "did:keri:Eroot",
471 "did:keri:Edevice",
472 &git_config,
473 )
474 .expect_err("must refuse to clobber");
475
476 assert!(matches!(err, CommitHookError::HooksPathOccupied { .. }));
477 assert_eq!(
478 GitConfigProvider::get(&git_config, "core.hooksPath").expect("get"),
479 Some("/repo/.husky".to_string()),
480 "the hook manager's path must survive untouched"
481 );
482 let msg = err.to_string();
484 assert!(msg.contains("/repo/.husky") && msg.contains("--git-scope skip"));
485 }
486
487 #[test]
488 fn enable_is_idempotent_when_hooks_path_is_already_ours() {
489 use crate::testing::fakes::FakeGitConfigProvider;
490
491 let tmp = tempfile::TempDir::new().expect("temp dir");
492 let ours = tmp.path().join(HOOKS_DIR);
493 let git_config = FakeGitConfigProvider::new();
494 git_config
495 .set("core.hooksPath", &ours.to_string_lossy())
496 .expect("ours already");
497
498 enable_commit_trailers(
499 tmp.path(),
500 "did:keri:Eroot",
501 "did:keri:Edevice",
502 &git_config,
503 )
504 .expect("re-running init over our own config must be fine");
505 }
506
507 #[test]
511 fn pre_push_only_chains_and_exits_clean() {
512 assert!(
513 PRE_PUSH_HOOK.contains("hooks/pre-push"),
514 "the managed hook must chain to the repo-local pre-push"
515 );
516 assert!(
517 PRE_PUSH_HOOK.trim_end().ends_with("exit 0"),
518 "a missing repo hook must exit clean, never fail the push"
519 );
520 assert!(
521 !PRE_PUSH_HOOK.contains("registry push"),
522 "the mirror is gone — KEL distribution is the committed bundle, not a pushed ref"
523 );
524 }
525}