Skip to main content

auths_sdk/workflows/
commit_hooks.rs

1//! Commit-time trailer injection — the machinery that makes a plain `git commit`
2//! produce a verifiable commit with **zero new verbs**.
3//!
4//! The `gpg.ssh.program` shim (`auths-sign`) can only return signature bytes; it
5//! cannot edit the commit message. The `Auths-Id` / `Auths-Device` trailers that
6//! KEL-native verification replays therefore go in at commit time via a
7//! `prepare-commit-msg` hook installed under `<auths_home>/githooks` and wired
8//! through the global `core.hooksPath`. The hook is deliberately dumb — it reads
9//! two static files written at init (no `auths` process spawn per commit):
10//!
11//! - `<auths_home>/commit-trailers` — the literal trailer lines to inject
12//! - `<auths_home>/root-pin`        — the `.auths/roots` content to seed into a
13//!   repo on first signed commit (the committed trust declaration teammates and
14//!   CI inherit)
15//!
16//! The hook chains to the repo's own `$GIT_DIR/hooks/prepare-commit-msg` when one
17//! exists. Repos that set a *local* `core.hooksPath` (husky-style managers)
18//! bypass the global path entirely; `auths doctor` detects that and the
19//! missing-trailer verify error explains the remedy.
20//!
21//! `Auths-Anchor-Seq` is intentionally NOT in the static trailer file: it is the
22//! root-KEL tip position *at signing time*, which changes as the KEL grows. The
23//! explicit `auths sign <ref>` repair path still embeds it.
24
25use std::path::{Path, PathBuf};
26
27use crate::ports::git_config::{GitConfigError, GitConfigProvider};
28
29/// File under `auths_home` holding the trailer lines the hook injects.
30pub const TRAILERS_FILE: &str = "commit-trailers";
31/// File under `auths_home` holding the `.auths/roots` seed content.
32pub const ROOT_PIN_FILE: &str = "root-pin";
33/// Directory under `auths_home` that `core.hooksPath` points at.
34pub const HOOKS_DIR: &str = "githooks";
35
36/// The managed `prepare-commit-msg` hook. Version-stamped so `auths doctor` can
37/// detect a stale install; bump the version when editing.
38///
39/// The pin block is a **repair path only** — `auths init` owns pinning and staging
40/// via `roots::pin_root_in_repo`. It exists for repos entered after init ran
41/// elsewhere. Two constraints shape it, both learned the hard way:
42///
43/// 1. It keys off **tracked-ness, not existence**. The previous version skipped
44///    when `.auths/roots` merely existed — which `auths init` had just created —
45///    so the pin was never staged and never travelled.
46/// 2. A `git add` here lands in the **next** commit, not this one: git has already
47///    snapshotted the index by the time `prepare-commit-msg` runs. The message says
48///    so rather than claiming otherwise.
49pub 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
89/// The managed `pre-push` hook. Exists only to chain: `core.hooksPath`
90/// *replaces* `.git/hooks`, so a managed hook that did not chain would silently
91/// disable every repo-local pre-push (husky, pre-commit, prek) on the machine.
92///
93/// It used to mirror `refs/auths/registry` to the push remote, but that mirror
94/// silently no-oped against real (ssh/https) remotes — the CLI's git layer links
95/// no network transport — so the published ref drifted from the signer's real
96/// KEL. KEL distribution is the committed identity bundle now
97/// (`auths id export-bundle` → `.auths/ci-bundle.json`), which travels with the
98/// code and needs no side-channel push.
99pub 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/// Failure installing the commit hook or writing its data files.
112#[derive(Debug, thiserror::Error)]
113pub enum CommitHookError {
114    /// A hook or data file could not be written.
115    #[error("could not write {path}: {source}")]
116    Write {
117        /// The path that failed.
118        path: PathBuf,
119        /// The underlying I/O error.
120        source: std::io::Error,
121    },
122    /// Setting `core.hooksPath` failed.
123    #[error("could not set core.hooksPath: {0}")]
124    GitConfig(#[from] GitConfigError),
125    /// The hooks directory path is not representable as UTF-8 for git config.
126    #[error("hooks path is not valid UTF-8: {0}")]
127    NonUtf8Path(PathBuf),
128    /// The local signing identity could not be resolved for a trailer refresh.
129    #[error("could not resolve the local signer: {0}")]
130    Signer(String),
131
132    /// `core.hooksPath` already points somewhere else — a hook manager owns it.
133    #[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        /// The path already configured.
138        existing: String,
139        /// The managed hooks directory auths wanted to point at.
140        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
157/// Write a hook script and mark it executable (no-op on non-unix).
158fn 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
173/// Path of the managed hook file under `auths_home`.
174///
175/// Args:
176/// * `auths_home`: The auths data directory (`~/.auths` or a CI registry path).
177///
178/// Usage:
179/// ```ignore
180/// let hook = hook_path(&auths_home);
181/// ```
182pub fn hook_path(auths_home: &Path) -> PathBuf {
183    auths_home.join(HOOKS_DIR).join("prepare-commit-msg")
184}
185
186/// Path of the managed `pre-push` hook file under `auths_home`.
187///
188/// Args:
189/// * `auths_home`: The auths data directory (`~/.auths` or a CI registry path).
190///
191/// Usage:
192/// ```ignore
193/// let hook = pre_push_hook_path(&auths_home);
194/// ```
195pub fn pre_push_hook_path(auths_home: &Path) -> PathBuf {
196    auths_home.join(HOOKS_DIR).join("pre-push")
197}
198
199/// Whether both managed hook files exist with the current script content.
200///
201/// Covers `prepare-commit-msg` *and* `pre-push`: a stale or missing pre-push
202/// means the signer's KEL never reaches the remote, which `auths doctor` should
203/// surface as an actionable "re-run auths init" rather than a silent gap.
204///
205/// Args:
206/// * `auths_home`: The auths data directory.
207///
208/// Usage:
209/// ```ignore
210/// if !hook_is_current(&auths_home) { /* doctor: re-run auths init */ }
211/// ```
212pub 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
222/// Install the `prepare-commit-msg` hook under `<auths_home>/githooks` and write
223/// the trailer + root-pin data files it reads.
224///
225/// Idempotent: rewrites the managed files unconditionally (a stale hook version
226/// is replaced). Does NOT touch git config — pair with
227/// [`enable_commit_trailers`] for the full wiring.
228///
229/// Args:
230/// * `auths_home`: The auths data directory the hook reads from at commit time.
231/// * `root_did`: The local identity's root `did:keri:` (the `Auths-Id` value).
232/// * `device_did`: This device's DID (the `Auths-Device` value).
233///
234/// Usage:
235/// ```ignore
236/// let hooks_dir = install_commit_hooks(&auths_home, &root_did, &device_did)?;
237/// ```
238pub 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
256/// Rewrite the trailer data file from the locally-resolved signer, including
257/// the current root-KEL position as `Auths-Anchor-Seq`.
258///
259/// Call this after any operation that advances the root KEL or changes the
260/// signing identity — rotation, device add/remove, agent add/revoke, pairing —
261/// so hook-stamped commits carry an up-to-date anchor position (the verifier
262/// uses it to order commits against later revocations). A missing anchor seq
263/// (non-transferable roots) simply omits the line.
264///
265/// Args:
266/// * `ctx`: Auths context (identity storage + registry, read-only).
267/// * `auths_home`: The auths data directory holding `commit-trailers`.
268///
269/// Usage:
270/// ```ignore
271/// refresh_commit_trailers(&ctx, &auths_home)?;
272/// ```
273pub 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
296/// Full commit-trailer wiring: install the hook + data files and point
297/// `core.hooksPath` at the managed hooks directory.
298///
299/// After this, a plain `git commit` produces a commit `auths verify` can replay —
300/// no extra commands, no per-repo setup.
301///
302/// Args:
303/// * `auths_home`: The auths data directory.
304/// * `root_did`: The local identity's root `did:keri:`.
305/// * `device_did`: This device's DID.
306/// * `git_config`: Git configuration provider (global or local scope).
307///
308/// Usage:
309/// ```ignore
310/// enable_commit_trailers(&auths_home, &root_did, &device_did, git_config)?;
311/// ```
312pub 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    // Never clobber a core.hooksPath we did not write. `core.hooksPath` *replaces*
324    // .git/hooks, so pointing it at ours would silently disable every hook a
325    // manager (husky, pre-commit, prek) had installed — a class of breakage the
326    // user would experience as "my pre-commit checks stopped running" with nothing
327    // to connect it to `auths init`. Refuse, and let the caller explain.
328    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    /// The hook must key the pin repair off *tracked-ness*, not existence. The v1
374    /// hook skipped whenever `.auths/roots` existed — which `auths init` had just
375    /// created — so the pin was never staged and third-party verification was
376    /// impossible. Guard the fix so it cannot silently regress.
377    #[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    /// `git add` inside prepare-commit-msg lands in the NEXT commit — git has
390    /// already snapshotted the index. The hook must not claim otherwise.
391    #[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    /// `core.hooksPath` *replaces* `.git/hooks`, so a managed hook that does not
439    /// chain silently disables every repo-local hook on the machine (husky,
440    /// pre-commit, prek). Both managed hooks must exec the repo's own.
441    #[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    /// `core.hooksPath` *replaces* `.git/hooks`, so pointing it at ours when a hook
455    /// manager already owns it silently disables every hook that manager installed.
456    /// The user experiences that as "my pre-commit checks stopped running", with
457    /// nothing connecting it to `auths init`. Refuse, and say what to do instead.
458    #[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        // The message has to be actionable, not just a refusal.
483        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    /// The pre-push hook chains and never blocks: no mirror, no network, no
508    /// side effects — its whole job is keeping repo-local hooks alive under
509    /// core.hooksPath.
510    #[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}