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.
38pub const PREPARE_COMMIT_MSG_HOOK: &str = r#"#!/bin/sh
39# auths prepare-commit-msg hook v1 — managed by `auths init`, checked by
40# `auths doctor`. Injects the Auths-Id / Auths-Device trailers so `auths verify`
41# can replay the signer's KEL, and seeds the repo's committed .auths/roots trust
42# pin on first use. Chains to the repo's own hook when one exists.
43
44MSG_FILE="$1"
45AUTHS_HOME="${AUTHS_REPO:-$HOME/.auths}"
46TRAILERS="$AUTHS_HOME/commit-trailers"
47
48if [ -f "$TRAILERS" ]; then
49    TOPLEVEL="$(git rev-parse --show-toplevel 2>/dev/null)"
50    if [ -n "$TOPLEVEL" ] && [ -f "$AUTHS_HOME/root-pin" ] && [ ! -e "$TOPLEVEL/.auths/roots" ]; then
51        mkdir -p "$TOPLEVEL/.auths" &&
52            cp "$AUTHS_HOME/root-pin" "$TOPLEVEL/.auths/roots" &&
53            git add -- "$TOPLEVEL/.auths/roots" >/dev/null 2>&1 &&
54            echo "auths: pinned your identity root in .auths/roots (committed with this commit)" >&2
55    fi
56    while IFS= read -r trailer; do
57        case "$trailer" in
58        '' | '#'*) ;;
59        *) git interpret-trailers --in-place --if-exists replace --trailer "$trailer" "$MSG_FILE" ;;
60        esac
61    done <"$TRAILERS"
62fi
63
64REPO_HOOK="$(git rev-parse --git-dir 2>/dev/null)/hooks/prepare-commit-msg"
65if [ -x "$REPO_HOOK" ]; then
66    exec "$REPO_HOOK" "$@"
67fi
68exit 0
69"#;
70
71/// Failure installing the commit hook or writing its data files.
72#[derive(Debug, thiserror::Error)]
73pub enum CommitHookError {
74    /// A hook or data file could not be written.
75    #[error("could not write {path}: {source}")]
76    Write {
77        /// The path that failed.
78        path: PathBuf,
79        /// The underlying I/O error.
80        source: std::io::Error,
81    },
82    /// Setting `core.hooksPath` failed.
83    #[error("could not set core.hooksPath: {0}")]
84    GitConfig(#[from] GitConfigError),
85    /// The hooks directory path is not representable as UTF-8 for git config.
86    #[error("hooks path is not valid UTF-8: {0}")]
87    NonUtf8Path(PathBuf),
88    /// The local signing identity could not be resolved for a trailer refresh.
89    #[error("could not resolve the local signer: {0}")]
90    Signer(String),
91}
92
93fn write_file(path: &Path, content: &str) -> Result<(), CommitHookError> {
94    if let Some(parent) = path.parent() {
95        std::fs::create_dir_all(parent).map_err(|source| CommitHookError::Write {
96            path: parent.to_path_buf(),
97            source,
98        })?;
99    }
100    std::fs::write(path, content).map_err(|source| CommitHookError::Write {
101        path: path.to_path_buf(),
102        source,
103    })
104}
105
106/// Path of the managed hook file under `auths_home`.
107///
108/// Args:
109/// * `auths_home`: The auths data directory (`~/.auths` or a CI registry path).
110///
111/// Usage:
112/// ```ignore
113/// let hook = hook_path(&auths_home);
114/// ```
115pub fn hook_path(auths_home: &Path) -> PathBuf {
116    auths_home.join(HOOKS_DIR).join("prepare-commit-msg")
117}
118
119/// Whether the managed hook file exists with the current script content.
120///
121/// Args:
122/// * `auths_home`: The auths data directory.
123///
124/// Usage:
125/// ```ignore
126/// if !hook_is_current(&auths_home) { /* doctor: re-run auths init */ }
127/// ```
128pub fn hook_is_current(auths_home: &Path) -> bool {
129    std::fs::read_to_string(hook_path(auths_home))
130        .map(|content| content == PREPARE_COMMIT_MSG_HOOK)
131        .unwrap_or(false)
132}
133
134/// Install the `prepare-commit-msg` hook under `<auths_home>/githooks` and write
135/// the trailer + root-pin data files it reads.
136///
137/// Idempotent: rewrites the managed files unconditionally (a stale hook version
138/// is replaced). Does NOT touch git config — pair with
139/// [`enable_commit_trailers`] for the full wiring.
140///
141/// Args:
142/// * `auths_home`: The auths data directory the hook reads from at commit time.
143/// * `root_did`: The local identity's root `did:keri:` (the `Auths-Id` value).
144/// * `device_did`: This device's DID (the `Auths-Device` value).
145///
146/// Usage:
147/// ```ignore
148/// let hooks_dir = install_commit_hooks(&auths_home, &root_did, &device_did)?;
149/// ```
150pub fn install_commit_hooks(
151    auths_home: &Path,
152    root_did: &str,
153    device_did: &str,
154) -> Result<PathBuf, CommitHookError> {
155    let hook = hook_path(auths_home);
156    write_file(&hook, PREPARE_COMMIT_MSG_HOOK)?;
157    #[cfg(unix)]
158    {
159        use std::os::unix::fs::PermissionsExt;
160        std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).map_err(
161            |source| CommitHookError::Write {
162                path: hook.clone(),
163                source,
164            },
165        )?;
166    }
167    write_file(
168        &auths_home.join(TRAILERS_FILE),
169        &format!("Auths-Id: {root_did}\nAuths-Device: {device_did}\n"),
170    )?;
171    write_file(
172        &auths_home.join(ROOT_PIN_FILE),
173        &format!("# Pinned by auths init — the trusted root for this identity.\n{root_did}\n"),
174    )?;
175    Ok(auths_home.join(HOOKS_DIR))
176}
177
178/// Rewrite the trailer data file from the locally-resolved signer, including
179/// the current root-KEL position as `Auths-Anchor-Seq`.
180///
181/// Call this after any operation that advances the root KEL or changes the
182/// signing identity — rotation, device add/remove, agent add/revoke, pairing —
183/// so hook-stamped commits carry an up-to-date anchor position (the verifier
184/// uses it to order commits against later revocations). A missing anchor seq
185/// (non-transferable roots) simply omits the line.
186///
187/// Args:
188/// * `ctx`: Auths context (identity storage + registry, read-only).
189/// * `auths_home`: The auths data directory holding `commit-trailers`.
190///
191/// Usage:
192/// ```ignore
193/// refresh_commit_trailers(&ctx, &auths_home)?;
194/// ```
195pub fn refresh_commit_trailers(
196    ctx: &crate::context::AuthsContext,
197    auths_home: &Path,
198) -> Result<(), CommitHookError> {
199    let signer = crate::domains::identity::local::resolve_local_signer(ctx)
200        .map_err(|e| CommitHookError::Signer(e.to_string()))?;
201    let mut content = format!(
202        "Auths-Id: {}\nAuths-Device: {}\n",
203        signer.root_did, signer.signer_did
204    );
205    if let Some(seq) = signer.anchor_seq {
206        content.push_str(&format!("{}\n", auths_verifier::anchor_seq_trailer(seq)));
207    }
208    write_file(&auths_home.join(TRAILERS_FILE), &content)?;
209    write_file(
210        &auths_home.join(ROOT_PIN_FILE),
211        &format!(
212            "# Pinned by auths init — the trusted root for this identity.\n{}\n",
213            signer.root_did
214        ),
215    )
216}
217
218/// Full commit-trailer wiring: install the hook + data files and point
219/// `core.hooksPath` at the managed hooks directory.
220///
221/// After this, a plain `git commit` produces a commit `auths verify` can replay —
222/// no extra commands, no per-repo setup.
223///
224/// Args:
225/// * `auths_home`: The auths data directory.
226/// * `root_did`: The local identity's root `did:keri:`.
227/// * `device_did`: This device's DID.
228/// * `git_config`: Git configuration provider (global or local scope).
229///
230/// Usage:
231/// ```ignore
232/// enable_commit_trailers(&auths_home, &root_did, &device_did, git_config)?;
233/// ```
234pub fn enable_commit_trailers(
235    auths_home: &Path,
236    root_did: &str,
237    device_did: &str,
238    git_config: &dyn GitConfigProvider,
239) -> Result<(), CommitHookError> {
240    let hooks_dir = install_commit_hooks(auths_home, root_did, device_did)?;
241    let hooks_dir_str = hooks_dir
242        .to_str()
243        .ok_or_else(|| CommitHookError::NonUtf8Path(hooks_dir.clone()))?;
244    git_config.set("core.hooksPath", hooks_dir_str)?;
245    Ok(())
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn install_writes_hook_and_data_files() {
254        let tmp = tempfile::TempDir::new().expect("temp dir");
255        let home = tmp.path();
256        let hooks_dir =
257            install_commit_hooks(home, "did:keri:Eroot", "did:keri:Edevice").expect("install");
258        assert_eq!(hooks_dir, home.join(HOOKS_DIR));
259        assert!(hook_is_current(home));
260        let trailers = std::fs::read_to_string(home.join(TRAILERS_FILE)).expect("trailers");
261        assert_eq!(
262            trailers,
263            "Auths-Id: did:keri:Eroot\nAuths-Device: did:keri:Edevice\n"
264        );
265        let pin = std::fs::read_to_string(home.join(ROOT_PIN_FILE)).expect("pin");
266        assert!(pin.lines().any(|l| l == "did:keri:Eroot"));
267    }
268
269    #[test]
270    fn stale_hook_is_not_current_and_reinstall_replaces_it() {
271        let tmp = tempfile::TempDir::new().expect("temp dir");
272        let home = tmp.path();
273        install_commit_hooks(home, "did:keri:Eroot", "did:keri:Edevice").expect("install");
274        std::fs::write(hook_path(home), "#!/bin/sh\n# old version\n").expect("overwrite");
275        assert!(!hook_is_current(home));
276        install_commit_hooks(home, "did:keri:Eroot", "did:keri:Edevice").expect("reinstall");
277        assert!(hook_is_current(home));
278    }
279
280    #[cfg(unix)]
281    #[test]
282    fn hook_is_executable() {
283        use std::os::unix::fs::PermissionsExt;
284        let tmp = tempfile::TempDir::new().expect("temp dir");
285        install_commit_hooks(tmp.path(), "did:keri:Eroot", "did:keri:Edevice").expect("install");
286        let mode = std::fs::metadata(hook_path(tmp.path()))
287            .expect("metadata")
288            .permissions()
289            .mode();
290        assert_eq!(mode & 0o111, 0o111, "hook must be executable");
291    }
292}