use std::path::{Path, PathBuf};
use crate::ports::git_config::{GitConfigError, GitConfigProvider};
pub const TRAILERS_FILE: &str = "commit-trailers";
pub const ROOT_PIN_FILE: &str = "root-pin";
pub const HOOKS_DIR: &str = "githooks";
pub const PREPARE_COMMIT_MSG_HOOK: &str = r#"#!/bin/sh
# auths prepare-commit-msg hook v2 — managed by `auths init`, checked by
# `auths doctor`. Injects the Auths-Id / Auths-Device trailers so `auths verify`
# can replay the signer's KEL, and repairs a missing/untracked .auths/roots trust
# pin (the committed trust declaration teammates and CI inherit).
# Chains to the repo's own hook when one exists.
MSG_FILE="$1"
AUTHS_HOME="${AUTHS_REPO:-$HOME/.auths}"
TRAILERS="$AUTHS_HOME/commit-trailers"
if [ -f "$TRAILERS" ]; then
TOPLEVEL="$(git rev-parse --show-toplevel 2>/dev/null)"
if [ -n "$TOPLEVEL" ] && [ -f "$AUTHS_HOME/root-pin" ]; then
PIN="$TOPLEVEL/.auths/roots"
[ -e "$PIN" ] || {
mkdir -p "$TOPLEVEL/.auths" && cp "$AUTHS_HOME/root-pin" "$PIN"
}
# Stage only when git isn't tracking it yet. This lands in the NEXT commit:
# git snapshotted the index before this hook ran.
if [ -e "$PIN" ] && ! git ls-files --error-unmatch -- "$PIN" >/dev/null 2>&1; then
git add -- "$PIN" >/dev/null 2>&1 &&
echo "auths: staged .auths/roots — your trust pin lands in your next commit" >&2
fi
fi
while IFS= read -r trailer; do
case "$trailer" in
'' | '#'*) ;;
*) git interpret-trailers --in-place --if-exists replace --trailer "$trailer" "$MSG_FILE" ;;
esac
done <"$TRAILERS"
fi
REPO_HOOK="$(git rev-parse --git-dir 2>/dev/null)/hooks/prepare-commit-msg"
if [ -x "$REPO_HOOK" ]; then
exec "$REPO_HOOK" "$@"
fi
exit 0
"#;
pub const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
# auths pre-push hook v1 — managed by `auths init`, checked by `auths doctor`.
# Mirrors refs/auths/registry to the remote being pushed to, so a clone of that
# remote carries the KEL `auths verify` needs. Fast-forward-only and non-fatal:
# a registry mirror must never block a code push. Disable with:
# git config auths.autopush false
REMOTE_URL="$2"
if [ -n "$REMOTE_URL" ] &&
[ "$(git config --get auths.autopush 2>/dev/null)" != "false" ] &&
command -v auths >/dev/null 2>&1; then
auths registry push "$REMOTE_URL" >/dev/null 2>&1 || true
fi
REPO_HOOK="$(git rev-parse --git-dir 2>/dev/null)/hooks/pre-push"
if [ -x "$REPO_HOOK" ]; then
exec "$REPO_HOOK" "$@"
fi
exit 0
"#;
#[derive(Debug, thiserror::Error)]
pub enum CommitHookError {
#[error("could not write {path}: {source}")]
Write {
path: PathBuf,
source: std::io::Error,
},
#[error("could not set core.hooksPath: {0}")]
GitConfig(#[from] GitConfigError),
#[error("hooks path is not valid UTF-8: {0}")]
NonUtf8Path(PathBuf),
#[error("could not resolve the local signer: {0}")]
Signer(String),
#[error(
"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."
)]
HooksPathOccupied {
existing: String,
wanted: String,
},
}
fn write_file(path: &Path, content: &str) -> Result<(), CommitHookError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| CommitHookError::Write {
path: parent.to_path_buf(),
source,
})?;
}
std::fs::write(path, content).map_err(|source| CommitHookError::Write {
path: path.to_path_buf(),
source,
})
}
fn write_executable(path: &Path, content: &str) -> Result<(), CommitHookError> {
write_file(path, content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).map_err(
|source| CommitHookError::Write {
path: path.to_path_buf(),
source,
},
)?;
}
Ok(())
}
pub fn hook_path(auths_home: &Path) -> PathBuf {
auths_home.join(HOOKS_DIR).join("prepare-commit-msg")
}
pub fn pre_push_hook_path(auths_home: &Path) -> PathBuf {
auths_home.join(HOOKS_DIR).join("pre-push")
}
pub fn hook_is_current(auths_home: &Path) -> bool {
let matches = |path: PathBuf, expected: &str| {
std::fs::read_to_string(path)
.map(|content| content == expected)
.unwrap_or(false)
};
matches(hook_path(auths_home), PREPARE_COMMIT_MSG_HOOK)
&& matches(pre_push_hook_path(auths_home), PRE_PUSH_HOOK)
}
pub fn install_commit_hooks(
auths_home: &Path,
root_did: &str,
device_did: &str,
) -> Result<PathBuf, CommitHookError> {
write_executable(&hook_path(auths_home), PREPARE_COMMIT_MSG_HOOK)?;
write_executable(&pre_push_hook_path(auths_home), PRE_PUSH_HOOK)?;
write_file(
&auths_home.join(TRAILERS_FILE),
&format!("Auths-Id: {root_did}\nAuths-Device: {device_did}\n"),
)?;
write_file(
&auths_home.join(ROOT_PIN_FILE),
&format!("# Pinned by auths init — the trusted root for this identity.\n{root_did}\n"),
)?;
Ok(auths_home.join(HOOKS_DIR))
}
pub fn refresh_commit_trailers(
ctx: &crate::context::AuthsContext,
auths_home: &Path,
) -> Result<(), CommitHookError> {
let signer = crate::domains::identity::local::resolve_local_signer(ctx)
.map_err(|e| CommitHookError::Signer(e.to_string()))?;
let mut content = format!(
"Auths-Id: {}\nAuths-Device: {}\n",
signer.root_did, signer.signer_did
);
if let Some(seq) = signer.anchor_seq {
content.push_str(&format!("{}\n", auths_verifier::anchor_seq_trailer(seq)));
}
write_file(&auths_home.join(TRAILERS_FILE), &content)?;
write_file(
&auths_home.join(ROOT_PIN_FILE),
&format!(
"# Pinned by auths init — the trusted root for this identity.\n{}\n",
signer.root_did
),
)
}
pub fn enable_commit_trailers(
auths_home: &Path,
root_did: &str,
device_did: &str,
git_config: &dyn GitConfigProvider,
) -> Result<(), CommitHookError> {
let hooks_dir = install_commit_hooks(auths_home, root_did, device_did)?;
let hooks_dir_str = hooks_dir
.to_str()
.ok_or_else(|| CommitHookError::NonUtf8Path(hooks_dir.clone()))?;
if let Some(existing) = git_config.get("core.hooksPath")?
&& existing != hooks_dir_str
{
return Err(CommitHookError::HooksPathOccupied {
existing,
wanted: hooks_dir_str.to_string(),
});
}
git_config.set("core.hooksPath", hooks_dir_str)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_writes_hook_and_data_files() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let home = tmp.path();
let hooks_dir =
install_commit_hooks(home, "did:keri:Eroot", "did:keri:Edevice").expect("install");
assert_eq!(hooks_dir, home.join(HOOKS_DIR));
assert!(hook_is_current(home));
let trailers = std::fs::read_to_string(home.join(TRAILERS_FILE)).expect("trailers");
assert_eq!(
trailers,
"Auths-Id: did:keri:Eroot\nAuths-Device: did:keri:Edevice\n"
);
let pin = std::fs::read_to_string(home.join(ROOT_PIN_FILE)).expect("pin");
assert!(pin.lines().any(|l| l == "did:keri:Eroot"));
}
#[test]
fn stale_hook_is_not_current_and_reinstall_replaces_it() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let home = tmp.path();
install_commit_hooks(home, "did:keri:Eroot", "did:keri:Edevice").expect("install");
std::fs::write(hook_path(home), "#!/bin/sh\n# old version\n").expect("overwrite");
assert!(!hook_is_current(home));
install_commit_hooks(home, "did:keri:Eroot", "did:keri:Edevice").expect("reinstall");
assert!(hook_is_current(home));
}
#[test]
fn hook_repairs_pin_on_tracked_ness_not_existence() {
assert!(
PREPARE_COMMIT_MSG_HOOK.contains("ls-files --error-unmatch"),
"pin repair must test tracked-ness, not mere existence"
);
assert!(
!PREPARE_COMMIT_MSG_HOOK.contains("[ ! -e \"$TOPLEVEL/.auths/roots\" ]"),
"the v1 existence guard defeated the mechanism it guarded"
);
}
#[test]
fn hook_does_not_claim_the_pin_lands_in_this_commit() {
assert!(
!PREPARE_COMMIT_MSG_HOOK.contains("committed with this commit"),
"false: a git add here lands in the next commit, not this one"
);
assert!(
PREPARE_COMMIT_MSG_HOOK.contains("next commit"),
"the hook must tell the truth about when the pin lands"
);
}
#[cfg(unix)]
#[test]
fn hook_is_executable() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().expect("temp dir");
install_commit_hooks(tmp.path(), "did:keri:Eroot", "did:keri:Edevice").expect("install");
for path in [hook_path(tmp.path()), pre_push_hook_path(tmp.path())] {
let mode = std::fs::metadata(&path)
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111, "{} must be executable", path.display());
}
}
#[test]
fn install_writes_the_pre_push_hook() {
let tmp = tempfile::TempDir::new().expect("temp dir");
install_commit_hooks(tmp.path(), "did:keri:Eroot", "did:keri:Edevice").expect("install");
let hook = std::fs::read_to_string(pre_push_hook_path(tmp.path())).expect("pre-push");
assert_eq!(hook, PRE_PUSH_HOOK);
assert!(hook_is_current(tmp.path()));
}
#[test]
fn stale_pre_push_is_detected_by_hook_is_current() {
let tmp = tempfile::TempDir::new().expect("temp dir");
install_commit_hooks(tmp.path(), "did:keri:Eroot", "did:keri:Edevice").expect("install");
std::fs::write(pre_push_hook_path(tmp.path()), "#!/bin/sh\n# old\n").expect("overwrite");
assert!(
!hook_is_current(tmp.path()),
"a stale pre-push means the KEL never reaches the remote; doctor must see it"
);
}
#[test]
fn managed_hooks_chain_to_the_repos_own_hook() {
for (name, hook) in [
("prepare-commit-msg", PREPARE_COMMIT_MSG_HOOK),
("pre-push", PRE_PUSH_HOOK),
] {
assert!(
hook.contains(&format!("hooks/{name}")) && hook.contains("exec \"$REPO_HOOK\""),
"{name} must chain to the repo's own hook"
);
}
}
#[test]
fn enable_refuses_to_clobber_a_hook_managers_hooks_path() {
use crate::testing::fakes::FakeGitConfigProvider;
let tmp = tempfile::TempDir::new().expect("temp dir");
let git_config = FakeGitConfigProvider::new();
git_config
.set("core.hooksPath", "/repo/.husky")
.expect("pre-existing hook manager");
let err = enable_commit_trailers(
tmp.path(),
"did:keri:Eroot",
"did:keri:Edevice",
&git_config,
)
.expect_err("must refuse to clobber");
assert!(matches!(err, CommitHookError::HooksPathOccupied { .. }));
assert_eq!(
GitConfigProvider::get(&git_config, "core.hooksPath").expect("get"),
Some("/repo/.husky".to_string()),
"the hook manager's path must survive untouched"
);
let msg = err.to_string();
assert!(msg.contains("/repo/.husky") && msg.contains("--git-scope skip"));
}
#[test]
fn enable_is_idempotent_when_hooks_path_is_already_ours() {
use crate::testing::fakes::FakeGitConfigProvider;
let tmp = tempfile::TempDir::new().expect("temp dir");
let ours = tmp.path().join(HOOKS_DIR);
let git_config = FakeGitConfigProvider::new();
git_config
.set("core.hooksPath", &ours.to_string_lossy())
.expect("ours already");
enable_commit_trailers(
tmp.path(),
"did:keri:Eroot",
"did:keri:Edevice",
&git_config,
)
.expect("re-running init over our own config must be fine");
}
#[test]
fn pre_push_is_non_fatal_and_opt_outable() {
assert!(
PRE_PUSH_HOOK.contains("|| true"),
"registry push failure must not fail the code push"
);
assert!(
PRE_PUSH_HOOK.contains("auths.autopush"),
"pushing to a remote you do not own must stay refusable"
);
}
}