auths_sdk/workflows/
commit_hooks.rs1use 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
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#[derive(Debug, thiserror::Error)]
73pub enum CommitHookError {
74 #[error("could not write {path}: {source}")]
76 Write {
77 path: PathBuf,
79 source: std::io::Error,
81 },
82 #[error("could not set core.hooksPath: {0}")]
84 GitConfig(#[from] GitConfigError),
85 #[error("hooks path is not valid UTF-8: {0}")]
87 NonUtf8Path(PathBuf),
88 #[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
106pub fn hook_path(auths_home: &Path) -> PathBuf {
116 auths_home.join(HOOKS_DIR).join("prepare-commit-msg")
117}
118
119pub 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
134pub 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
178pub 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
218pub 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}