use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::{self, SigningConfig, VtaCredentials};
pub struct InstallArgs<'a> {
pub global: bool,
pub did_key_id: String,
pub vta_key_id: String,
pub credential_did: String,
pub credential_private_key_mb: String,
pub vta_did: String,
pub vta_url: String,
pub mediator_did: Option<String>,
pub user_name: Option<String>,
pub verifying_key: &'a [u8; 32],
}
pub struct InstallResult {
pub config_path: PathBuf,
pub ssh_public_key: String,
pub overridden_global_signing_key: Option<String>,
pub global_committer_email: Option<String>,
}
pub fn install(args: InstallArgs<'_>) -> Result<InstallResult> {
let cfg = SigningConfig {
did_key_id: args.did_key_id.clone(),
user_name: args.user_name,
};
let vta_creds = VtaCredentials {
vta_url: args.vta_url,
vta_did: args.vta_did,
credential_did: args.credential_did,
private_key_multibase: args.credential_private_key_mb,
key_id: args.vta_key_id,
mediator_did: args.mediator_did,
};
let config_path = if args.global {
SigningConfig::default_global_path()?
} else {
SigningConfig::repo_local_path()
};
cfg.save(&config_path)?;
config::store_vta_credentials(&args.did_key_id, &vta_creds)?;
setup_git(&config_path, &cfg, args.global)?;
let entry = allowed_signers_entry(&cfg, args.verifying_key);
let config_dir = config_path.parent().unwrap_or(Path::new("."));
setup_allowed_signers(config_dir, &entry, args.global)?;
if let Err(e) = install_hook_dispatcher(args.global) {
eprintln!(
"warning: could not install the git hook that writes the Signed-by-DID trailer:\n \
{e}\n \
Until this is resolved, `git commit` will refuse to sign in this repository: \
a commit with no DID claim cannot be verified. Resolve the conflict above and \
re-run `did-git-sign init`, or set user.email to '{}' as the legacy claim.",
cfg.did_key_id
);
}
let overridden_global_signing_key = (!args.global)
.then(|| {
std::process::Command::new("git")
.args(["config", "--global", "user.signingKey"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
})
.flatten();
Ok(InstallResult {
config_path,
ssh_public_key: ssh_public_key_string(args.verifying_key),
overridden_global_signing_key,
global_committer_email: args.global.then(|| cfg.did_key_id.clone()),
})
}
pub fn uninstall(global: bool, did_key_id: &str) -> Result<UninstallResult> {
let mut summary = UninstallResult::default();
let config_path = if global {
SigningConfig::default_global_path()?
} else {
SigningConfig::repo_local_path()
};
if config_path.exists() {
match std::fs::remove_file(&config_path) {
Ok(()) => {
summary.removed_config_file = Some(config_path.clone());
}
Err(e) => {
summary
.warnings
.push(format!("could not remove {}: {e}", config_path.display()));
}
}
}
for suffix in [":vta", ":token"] {
let key = format!("{did_key_id}{suffix}");
if let Ok(entry) = keyring_core::Entry::new(config::KEYRING_SERVICE, &key) {
match entry.delete_credential() {
Ok(()) => summary.removed_keyring_entries.push(key),
Err(keyring_core::Error::NoEntry) => {}
Err(e) => {
summary
.warnings
.push(format!("could not remove keyring entry '{key}': {e}"));
}
}
}
}
let signers_path = config_path
.parent()
.unwrap_or(Path::new("."))
.join("allowed_signers");
if signers_path.exists() {
match std::fs::read_to_string(&signers_path) {
Ok(content) => {
let prefix = format!("{did_key_id} ");
let mut kept = Vec::new();
let mut removed = false;
for line in content.lines() {
if line.trim_start().starts_with(&prefix) {
removed = true;
} else {
kept.push(line);
}
}
if removed {
let mut new_content = kept.join("\n");
if !new_content.is_empty() {
new_content.push('\n');
}
if let Err(e) = write_file_atomic(&signers_path, &new_content) {
summary
.warnings
.push(format!("could not rewrite {}: {e}", signers_path.display()));
} else {
summary.allowed_signers_entry_removed = true;
}
}
}
Err(e) => {
summary
.warnings
.push(format!("could not read {}: {e}", signers_path.display()));
}
}
}
let scope = if global { "--global" } else { "--local" };
for key in [
"user.signingKey",
"gpg.format",
"gpg.ssh.program",
"gpg.ssh.defaultKeyFile",
"gpg.ssh.allowedSignersFile",
"commit.gpgsign",
"did-git-sign.key",
] {
if git_config_unset(scope, key) {
summary.git_config_keys_unset.push(key.to_string());
}
}
match unset_did_git_sign_hooks_path(scope, global) {
Ok(true) => summary
.git_config_keys_unset
.push("core.hooksPath".to_string()),
Ok(false) => {}
Err(e) => summary
.warnings
.push(format!("could not inspect core.hooksPath: {e}")),
}
Ok(summary)
}
#[derive(Debug, Default)]
pub struct UninstallResult {
pub removed_config_file: Option<PathBuf>,
pub removed_keyring_entries: Vec<String>,
pub allowed_signers_entry_removed: bool,
pub git_config_keys_unset: Vec<String>,
pub warnings: Vec<String>,
}
fn git_config_unset(scope: &str, key: &str) -> bool {
Command::new("git")
.arg("config")
.arg(scope)
.arg("--unset")
.arg(key)
.output()
.ok()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn unset_did_git_sign_hooks_path(scope: &str, global: bool) -> Result<bool> {
let Some(expected) = expected_hooks_dir(global)? else {
return Ok(false);
};
let Some(configured) = git_config_get(scope, "core.hooksPath")? else {
return Ok(false);
};
if Path::new(configured.trim()) == expected {
return Ok(git_config_unset(scope, "core.hooksPath"));
}
Ok(false)
}
fn expected_hooks_dir(global: bool) -> Result<Option<PathBuf>> {
if global {
return Ok(Some(
dirs::config_dir()
.context("cannot determine config directory")?
.join("did-git-sign")
.join("hooks"),
));
}
let output = Command::new("git")
.args(["rev-parse", "--absolute-git-dir"])
.output()
.context("failed to find .git directory")?;
if !output.status.success() {
return Ok(None);
}
let git_dir = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
Ok(Some(git_dir.join("did-git-sign-hooks")))
}
pub fn setup_git(config_path: &Path, cfg: &SigningConfig, global: bool) -> Result<()> {
let scope = if global { "--global" } else { "--local" };
let config_path_str = config_path
.to_str()
.context("config path is not valid UTF-8")?;
git_config(scope, "gpg.format", "ssh")?;
git_config(scope, "gpg.ssh.program", "did-git-sign")?;
git_config(scope, "user.signingKey", config_path_str)?;
git_config(scope, "gpg.ssh.defaultKeyFile", config_path_str)?;
git_config(scope, "commit.gpgsign", "true")?;
git_config(scope, "did-git-sign.key", &cfg.did_key_id)?;
if let Some(name) = &cfg.user_name {
git_config(scope, "user.name", name)?;
}
Ok(())
}
pub fn allowed_signers_entry(cfg: &SigningConfig, public_key_bytes: &[u8; 32]) -> String {
let pub_b64 = base64_encode_pubkey(public_key_bytes);
format!("{} ssh-ed25519 {}", cfg.did_key_id, pub_b64)
}
fn write_file_atomic(path: &Path, contents: &str) -> Result<()> {
let dir = path.parent().unwrap_or(Path::new("."));
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("allowed_signers");
let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id()));
std::fs::write(&tmp, contents).with_context(|| format!("failed to write {}", tmp.display()))?;
match std::fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e).with_context(|| format!("failed to replace {}", path.display()))
}
}
}
pub fn setup_allowed_signers(config_dir: &Path, entry: &str, global: bool) -> Result<()> {
let signers_path = config_dir.join("allowed_signers");
let signers_path_str = signers_path
.to_str()
.context("signers path is not valid UTF-8")?;
let existing = std::fs::read_to_string(&signers_path).unwrap_or_default();
if !existing.contains(entry) {
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str(entry);
content.push('\n');
write_file_atomic(&signers_path, &content)?;
}
let scope = if global { "--global" } else { "--local" };
git_config(scope, "gpg.ssh.allowedSignersFile", signers_path_str)?;
Ok(())
}
fn git_config(scope: &str, key: &str, value: &str) -> Result<()> {
let output = Command::new("git")
.arg("config")
.arg(scope)
.arg(key)
.arg(value)
.output()
.context("failed to run git config")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git config {scope} {key} failed: {stderr}");
}
Ok(())
}
fn git_config_get(scope: &str, key: &str) -> Result<Option<String>> {
let output = Command::new("git")
.arg("config")
.arg(scope)
.arg("--get")
.arg(key)
.output()
.context("failed to run git config")?;
if output.status.success() {
return Ok(Some(
String::from_utf8_lossy(&output.stdout)
.trim_end_matches(['\r', '\n'])
.to_string(),
));
}
if output.status.code() == Some(1) {
return Ok(None);
}
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git config {scope} --get {key} failed: {stderr}");
}
pub fn ssh_public_key_string(public_key_bytes: &[u8; 32]) -> String {
format!("ssh-ed25519 {}", base64_encode_pubkey(public_key_bytes))
}
fn base64_encode_pubkey(public_key_bytes: &[u8; 32]) -> String {
use base64::Engine;
let mut blob = Vec::new();
let key_type = b"ssh-ed25519";
blob.extend_from_slice(&(key_type.len() as u32).to_be_bytes());
blob.extend_from_slice(key_type);
blob.extend_from_slice(&(public_key_bytes.len() as u32).to_be_bytes());
blob.extend_from_slice(public_key_bytes);
base64::engine::general_purpose::STANDARD.encode(&blob)
}
pub const COMMIT_MSG_HOOK: &str = r#"#!/bin/sh
# Installed by did-git-sign — chains the repo commit-msg hook, then adds the Signed-by-DID trailer.
# did-git-sign-hook-version: 2
git_dir=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0
repo_hook="$git_dir/hooks/commit-msg"
if [ -x "$repo_hook" ] && [ "$repo_hook" != "$0" ]; then
"$repo_hook" "$@" || exit $?
fi
msg_file="$1"
[ -n "$msg_file" ] || exit 0
# Same precedence the signer uses (R-G-1): env var, then per-repo git config.
# They must agree — the hook writes the claim and the signer checks it against
# the key it actually uses, so reading a different selector here would make
# `DID_GIT_SIGN_KEY=… git commit` refuse to sign its own commit.
DID=$(printf '%s' "${DID_GIT_SIGN_KEY:-}" | tr -d '\r\n')
[ -z "$DID" ] && DID=$(git config did-git-sign.key 2>/dev/null)
[ -z "$DID" ] && exit 0
case "$DID" in
did:*) ;;
*) exit 0 ;;
esac
case "$DID" in
*[[:space:]]*)
echo "did-git-sign: the selected DID contains whitespace; refusing to write a trailer" >&2
exit 1
;;
esac
# Opt-in: a DCO sign-off is the committer's assertion to make, not ours.
if [ "$(git config --bool did-git-sign.signoff 2>/dev/null)" = "true" ]; then
NAME=$(git config user.name 2>/dev/null | tr -d '\r\n')
EMAIL=$(git config user.email 2>/dev/null | tr -d '\r\n')
git interpret-trailers --in-place --no-divider --if-exists doNothing \
--trailer "Signed-off-by: $NAME <$EMAIL>" "$msg_file" || exit 1
fi
# --no-divider: a commit message has no patch after it, so a `---` line is
# text, and the final paragraph is the trailer block git log and verify-trust
# read. Without it the trailer lands above the first `---`, where nobody looks.
#
# An existing claim is kept (amend, rebase). Existence is tested on the exact
# key rather than with `--if-exists doNothing`, which matches keys by prefix:
# a `Signed-by:` or `S:` trailer would count as a claim and none would be
# written.
existing=$(git interpret-trailers --parse --no-divider "$msg_file") || exit 1
if ! printf '%s\n' "$existing" | grep -qi '^Signed-by-DID:'; then
git interpret-trailers --in-place --no-divider --where end --if-exists add \
--trailer "Signed-by-DID: $DID" "$msg_file" || exit 1
fi
"#;
pub const COMMIT_MSG_HOOK_VERSION: u32 = 2;
const HOOK_VERSION_MARKER: &str = "# did-git-sign-hook-version:";
const HOOK_OWNER_MARKER: &str = "Installed by did-git-sign";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommitMsgHookStatus {
Current { path: PathBuf },
Outdated {
path: PathBuf,
installed: u32,
current: u32,
},
Newer {
path: PathBuf,
installed: u32,
current: u32,
},
Foreign { path: PathBuf },
Missing { path: PathBuf },
Unknown,
}
fn classify_commit_msg_hook(path: PathBuf, content: Option<&str>) -> CommitMsgHookStatus {
let Some(content) = content else {
return CommitMsgHookStatus::Missing { path };
};
if !content.contains(HOOK_OWNER_MARKER) {
return CommitMsgHookStatus::Foreign { path };
}
let installed = content
.lines()
.find_map(|line| line.trim().strip_prefix(HOOK_VERSION_MARKER))
.map(|version| version.trim().parse::<u32>().unwrap_or(0))
.unwrap_or(1);
let current = COMMIT_MSG_HOOK_VERSION;
match installed.cmp(¤t) {
std::cmp::Ordering::Equal => CommitMsgHookStatus::Current { path },
std::cmp::Ordering::Less => CommitMsgHookStatus::Outdated {
path,
installed,
current,
},
std::cmp::Ordering::Greater => CommitMsgHookStatus::Newer {
path,
installed,
current,
},
}
}
pub fn commit_msg_hook_status() -> Result<CommitMsgHookStatus> {
let output = Command::new("git")
.args(["rev-parse", "--git-path", "hooks/commit-msg"])
.output()
.context("failed to run git")?;
let path = if output.status.success() {
let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
std::env::current_dir()
.map(|cwd| cwd.join(&path))
.unwrap_or(path)
} else {
match git_config_get("--global", "core.hooksPath")? {
Some(dir) => PathBuf::from(dir.trim()).join("commit-msg"),
None => return Ok(CommitMsgHookStatus::Unknown),
}
};
let content = std::fs::read_to_string(&path).ok();
Ok(classify_commit_msg_hook(path, content.as_deref()))
}
const STANDARD_GIT_HOOKS: &[&str] = &[
"applypatch-msg",
"commit-msg",
"fsmonitor-watchman",
"post-applypatch",
"post-checkout",
"post-commit",
"post-index-change",
"post-merge",
"post-receive",
"post-rewrite",
"post-update",
"pre-applypatch",
"pre-auto-gc",
"pre-commit",
"pre-merge-commit",
"pre-push",
"pre-rebase",
"pre-receive",
"prepare-commit-msg",
"proc-receive",
"push-to-checkout",
"reference-transaction",
"sendemail-validate",
"update",
];
fn delegating_hook(hook_name: &str) -> String {
format!(
r#"#!/bin/sh
# Installed by did-git-sign — delegates to the repository's {hook_name} hook.
git_dir=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0
repo_hook="$git_dir/hooks/{hook_name}"
[ -x "$repo_hook" ] || exit 0
[ "$repo_hook" != "$0" ] || exit 0
exec "$repo_hook" "$@"
"#
)
}
fn install_hook_dispatcher(global: bool) -> Result<()> {
let hooks_dir = expected_hooks_dir(global)?
.context("not inside a git repository — cannot install hook dispatcher")?;
let scope = if global { "--global" } else { "--local" };
if let Some(existing) = git_config_get(scope, "core.hooksPath")?
&& Path::new(existing.trim()) != hooks_dir
{
anyhow::bail!(
"{scope} core.hooksPath is already set to '{existing}'; refusing to overwrite it. \
Unset it, or add the Signed-by-DID trailer logic to that directory's commit-msg \
hook manually."
);
}
let hooks_dir_str = hooks_dir
.to_str()
.context("hooks directory path is not valid UTF-8")?
.to_string();
std::fs::create_dir_all(&hooks_dir)?;
for hook_name in STANDARD_GIT_HOOKS {
let hook_path = hooks_dir.join(hook_name);
let content = if *hook_name == "commit-msg" {
COMMIT_MSG_HOOK.to_string()
} else {
delegating_hook(hook_name)
};
write_executable_hook(&hook_path, &content)?;
}
git_config(scope, "core.hooksPath", &hooks_dir_str)?;
Ok(())
}
fn write_executable_hook(hook_path: &Path, content: &str) -> Result<()> {
if hook_path.exists() {
let existing = std::fs::read_to_string(hook_path).unwrap_or_default();
if existing.contains("Installed by did-git-sign") {
std::fs::write(hook_path, content)?;
} else {
anyhow::bail!(
"hook already exists at {}; merge manually",
hook_path.display()
);
}
} else {
std::fs::write(hook_path, content)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(hook_path, std::fs::Permissions::from_mode(0o755))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_base64_pubkey_format() {
let key = [0u8; 32];
let encoded = base64_encode_pubkey(&key);
assert!(!encoded.is_empty());
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(&encoded)
.unwrap();
assert_eq!(decoded.len(), 51);
}
#[test]
fn test_allowed_signers_entry_format() {
let cfg = SigningConfig {
did_key_id: "did:webvh:abc:example.com#key-0".to_string(),
user_name: None,
};
let key = [0u8; 32];
let entry = allowed_signers_entry(&cfg, &key);
assert!(entry.starts_with("did:webvh:abc:example.com#key-0 ssh-ed25519 "));
}
#[test]
fn test_ssh_public_key_string_format() {
let key = [0u8; 32];
let result = ssh_public_key_string(&key);
assert!(result.starts_with("ssh-ed25519 "));
let b64_part = result.strip_prefix("ssh-ed25519 ").unwrap();
let decoded =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64_part).unwrap();
assert_eq!(decoded.len(), 51);
}
#[test]
fn test_allowed_signers_entry_contains_valid_ssh_key() {
let cfg = SigningConfig {
did_key_id: "did:webvh:test:host#key-0".to_string(),
user_name: Some("Test User".to_string()),
};
let key = [0xFF; 32];
let entry = allowed_signers_entry(&cfg, &key);
let parts: Vec<&str> = entry.splitn(3, ' ').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0], "did:webvh:test:host#key-0");
assert_eq!(parts[1], "ssh-ed25519");
assert!(
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, parts[2],).is_ok()
);
}
#[test]
fn test_base64_pubkey_encodes_key_type_and_bytes() {
let key = [0x42; 32];
let encoded = base64_encode_pubkey(&key);
let decoded =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &encoded).unwrap();
assert_eq!(&decoded[0..4], &(11u32).to_be_bytes());
assert_eq!(&decoded[4..15], b"ssh-ed25519");
assert_eq!(&decoded[15..19], &(32u32).to_be_bytes());
assert_eq!(&decoded[19..51], &[0x42; 32]);
}
#[test]
fn test_setup_allowed_signers_creates_file() {
let dir = tempfile::tempdir().unwrap();
let entry = "did:webvh:test:host#key-0 ssh-ed25519 AAAA";
let signers_path = dir.path().join("allowed_signers");
let content = format!("{entry}\n");
std::fs::write(&signers_path, &content).unwrap();
let read_back = std::fs::read_to_string(&signers_path).unwrap();
assert!(read_back.contains(entry));
}
#[test]
fn atomic_write_replaces_contents_and_leaves_no_temp_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("allowed_signers");
write_file_atomic(&path, "first\n").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first\n");
write_file_atomic(&path, "second\n").unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"second\n",
"the rename must replace, not append"
);
let strays: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n != "allowed_signers")
.collect();
assert!(strays.is_empty(), "temp files left behind: {strays:?}");
}
#[test]
fn atomic_write_never_exposes_a_truncated_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("allowed_signers");
let long = "did:webvh:test:host#key-0 ssh-ed25519 AAAA\n".repeat(500);
write_file_atomic(&path, &long).unwrap();
for _ in 0..20 {
write_file_atomic(&path, &long).unwrap();
let seen = std::fs::read_to_string(&path).expect("target always exists");
assert_eq!(seen.len(), long.len(), "a reader saw a partial file");
}
}
#[test]
fn delegating_hook_targets_default_repo_hook_dir() {
let hook = delegating_hook("pre-push");
assert!(hook.contains("git rev-parse --absolute-git-dir"));
assert!(hook.contains("$git_dir/hooks/pre-push"));
assert!(hook.contains("exec \"$repo_hook\" \"$@\""));
}
#[cfg(unix)]
fn repo_with_commit_msg_hook(dir: &Path, did: &str) -> PathBuf {
for args in [
vec!["init", "-q"],
vec!["config", "user.name", "T Ester"],
vec!["config", "user.email", "t@example.com"],
vec!["config", "commit.gpgsign", "false"],
vec!["config", "did-git-sign.key", did],
] {
let out = Command::new("git")
.args(["-C", dir.to_str().unwrap()])
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?} failed");
}
let hook = dir.join(".git").join("hooks").join("commit-msg");
std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
write_executable_hook(&hook, COMMIT_MSG_HOOK).unwrap();
hook
}
#[cfg(unix)]
fn git_trailer(dir: &Path, key: &str) -> String {
let out = Command::new("git")
.args([
"-C",
dir.to_str().unwrap(),
"log",
"-1",
&format!("--format=%(trailers:key={key},valueonly)"),
])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[test]
#[cfg(unix)]
#[serial_test::serial]
fn commit_msg_hook_trailer_is_readable_by_git_on_a_one_line_message() {
let dir = tempfile::tempdir().unwrap();
let did = "did:webvh:QmAbc:example.com#key-0";
repo_with_commit_msg_hook(dir.path(), did);
std::fs::write(dir.path().join("f.txt"), "hi").unwrap();
for args in [vec!["add", "f.txt"], vec!["commit", "-q", "-m", "subject"]] {
let out = Command::new("git")
.args(["-C", dir.path().to_str().unwrap()])
.args(&args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
assert_eq!(
git_trailer(dir.path(), "Signed-by-DID"),
did,
"git itself must parse the trailer the hook wrote"
);
}
#[test]
#[cfg(unix)]
#[serial_test::serial]
fn commit_msg_hook_terminates_on_a_message_with_trailing_blank_lines() {
let dir = tempfile::tempdir().unwrap();
let did = "did:webvh:QmAbc:example.com#key-0";
let hook = repo_with_commit_msg_hook(dir.path(), did);
let msg = dir.path().join("MSG");
std::fs::write(&msg, "a message\n\n\n\n").unwrap();
let mut child = Command::new(&hook)
.arg(&msg)
.current_dir(dir.path())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let status = loop {
match child.try_wait().unwrap() {
Some(status) => break status,
None if std::time::Instant::now() >= deadline => {
let _ = child.kill();
panic!("commit-msg hook did not terminate — the trailing-blank-line loop spun");
}
None => std::thread::sleep(std::time::Duration::from_millis(20)),
}
};
assert!(status.success(), "hook failed: {status}");
assert!(
std::fs::read_to_string(&msg).unwrap().contains(did),
"hook must still write the trailer"
);
}
#[cfg(unix)]
fn commit_through_hook(dir: &Path, message: &str) -> (String, Option<String>) {
let msg = dir.join("MSG");
std::fs::write(&msg, message).unwrap();
let out = Command::new("git")
.args(["-C", dir.to_str().unwrap()])
.args(["commit", "-q", "--allow-empty", "-F", msg.to_str().unwrap()])
.output()
.unwrap();
assert!(
out.status.success(),
"git commit failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let raw = Command::new("git")
.args(["-C", dir.to_str().unwrap(), "cat-file", "commit", "HEAD"])
.output()
.unwrap()
.stdout;
(
git_trailer(dir, "Signed-by-DID"),
vgi_core::signer_did(&raw),
)
}
#[test]
#[cfg(unix)]
#[serial_test::serial]
fn commit_msg_hook_trailer_lands_in_the_final_block_past_a_divider() {
let dir = tempfile::tempdir().unwrap();
let did = "did:webvh:QmAbc:example.com#key-0";
repo_with_commit_msg_hook(dir.path(), did);
let messages = [
"chore(deps): bump yaml-rust2 from 0.11.1 to 0.13.0\n\n\
Bumps yaml-rust2 from 0.11.1 to 0.13.0.\n\
---\n\
updated-dependencies:\n\
- dependency-name: yaml-rust2\n dependency-version: 0.13.0\n",
"fix the thing\n\nexplanation\n\n---\n\
diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b\n",
"subject\n\nbody\n---\n",
];
for message in messages {
let (logged, verified) = commit_through_hook(dir.path(), message);
assert_eq!(
logged, did,
"git log must see the claim; message: {message:?}"
);
assert_eq!(
verified.as_deref(),
Some("did:webvh:QmAbc:example.com"),
"verify-trust must see the claim; message: {message:?}"
);
}
}
#[test]
#[cfg(unix)]
#[serial_test::serial]
fn commit_msg_hook_and_verifier_agree_on_an_existing_claim_past_a_divider() {
let dir = tempfile::tempdir().unwrap();
repo_with_commit_msg_hook(dir.path(), "did:webvh:QmAbc:example.com#key-0");
let (logged, verified) = commit_through_hook(
dir.path(),
"subject\n\nbody\n---\nquoted\n\nSigned-by-DID: did:webvh:QmOld:example.com\n",
);
assert_eq!(logged, "did:webvh:QmOld:example.com");
assert_eq!(verified.as_deref(), Some("did:webvh:QmOld:example.com"));
let out = Command::new("git")
.args([
"-C",
dir.path().to_str().unwrap(),
"log",
"-1",
"--format=%B",
])
.output()
.unwrap();
let body = String::from_utf8_lossy(&out.stdout);
assert!(
!body.contains("QmAbc"),
"doNothing must not add a second, unread claim: {body}"
);
}
#[test]
#[cfg(unix)]
#[serial_test::serial]
fn commit_msg_hook_is_not_fooled_by_a_key_that_prefixes_its_own() {
let dir = tempfile::tempdir().unwrap();
let did = "did:webvh:QmAbc:example.com#key-0";
repo_with_commit_msg_hook(dir.path(), did);
for message in [
"subject\n\nSigned-by: someone\n",
"subject\n\nS: x\n",
"subject\n\nSigned-by-DIDX: x\n",
] {
let (logged, verified) = commit_through_hook(dir.path(), message);
assert_eq!(logged, did, "message: {message:?}");
assert_eq!(
verified.as_deref(),
Some("did:webvh:QmAbc:example.com"),
"message: {message:?}"
);
}
}
#[test]
fn the_hook_declares_the_current_version() {
assert!(COMMIT_MSG_HOOK.contains(&format!(
"{HOOK_VERSION_MARKER} {COMMIT_MSG_HOOK_VERSION}\n"
)));
assert_eq!(
classify_commit_msg_hook(PathBuf::from("h"), Some(COMMIT_MSG_HOOK)),
CommitMsgHookStatus::Current {
path: PathBuf::from("h")
}
);
}
#[test]
fn a_hook_without_a_version_marker_is_outdated() {
let v1 = "#!/bin/sh\n# Installed by did-git-sign — chains the repo commit-msg hook, \
then adds the Signed-by-DID trailer.\n\
git interpret-trailers --in-place --if-exists doNothing \\\n";
assert_eq!(
classify_commit_msg_hook(PathBuf::from("h"), Some(v1)),
CommitMsgHookStatus::Outdated {
path: PathBuf::from("h"),
installed: 1,
current: COMMIT_MSG_HOOK_VERSION,
}
);
}
#[test]
fn hook_classification_covers_missing_foreign_and_newer() {
let path = PathBuf::from("h");
assert_eq!(
classify_commit_msg_hook(path.clone(), None),
CommitMsgHookStatus::Missing { path: path.clone() }
);
assert_eq!(
classify_commit_msg_hook(path.clone(), Some("#!/bin/sh\nnpx commitlint\n")),
CommitMsgHookStatus::Foreign { path: path.clone() }
);
let newer = COMMIT_MSG_HOOK.replace(
&format!("{HOOK_VERSION_MARKER} {COMMIT_MSG_HOOK_VERSION}"),
&format!("{HOOK_VERSION_MARKER} {}", COMMIT_MSG_HOOK_VERSION + 1),
);
assert_eq!(
classify_commit_msg_hook(path.clone(), Some(&newer)),
CommitMsgHookStatus::Newer {
path,
installed: COMMIT_MSG_HOOK_VERSION + 1,
current: COMMIT_MSG_HOOK_VERSION,
}
);
}
#[test]
#[serial_test::serial]
fn commit_msg_hook_status_follows_core_hooks_path() {
let dir = tempfile::tempdir().unwrap();
let hooks = dir.path().join("my-hooks");
std::fs::create_dir_all(&hooks).unwrap();
for args in [
vec!["init", "-q"],
vec!["config", "core.hooksPath", hooks.to_str().unwrap()],
] {
assert!(
Command::new("git")
.args(["-C", dir.path().to_str().unwrap()])
.args(&args)
.status()
.unwrap()
.success()
);
}
let _cwd = CwdGuard::change_to(dir.path());
let status = commit_msg_hook_status().unwrap();
let CommitMsgHookStatus::Missing { path } = status else {
panic!("expected Missing, got {status:?}");
};
assert_eq!(
path.parent().unwrap().canonicalize().unwrap(),
hooks.canonicalize().unwrap()
);
std::fs::write(hooks.join("commit-msg"), COMMIT_MSG_HOOK).unwrap();
assert!(matches!(
commit_msg_hook_status().unwrap(),
CommitMsgHookStatus::Current { .. }
));
}
#[test]
#[cfg(unix)]
#[serial_test::serial]
fn commit_msg_hook_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let did = "did:webvh:QmAbc:example.com#key-0";
let hook = repo_with_commit_msg_hook(dir.path(), did);
let msg = dir.path().join("MSG");
std::fs::write(&msg, "subject\n").unwrap();
for _ in 0..2 {
assert!(
Command::new(&hook)
.arg(&msg)
.current_dir(dir.path())
.status()
.unwrap()
.success()
);
}
let body = std::fs::read_to_string(&msg).unwrap();
assert_eq!(
body.matches("Signed-by-DID:").count(),
1,
"trailer must not be duplicated: {body}"
);
}
#[test]
#[cfg(unix)]
#[serial_test::serial]
fn commit_msg_hook_adds_signoff_only_when_opted_in() {
let dir = tempfile::tempdir().unwrap();
let did = "did:webvh:QmAbc:example.com#key-0";
let hook = repo_with_commit_msg_hook(dir.path(), did);
let msg = dir.path().join("MSG");
std::fs::write(&msg, "subject\n").unwrap();
Command::new(&hook)
.arg(&msg)
.current_dir(dir.path())
.status()
.unwrap();
assert!(
!std::fs::read_to_string(&msg)
.unwrap()
.contains("Signed-off-by:"),
"sign-off must not be added by default"
);
assert!(
Command::new("git")
.args([
"-C",
dir.path().to_str().unwrap(),
"config",
"did-git-sign.signoff",
"true",
])
.status()
.unwrap()
.success()
);
std::fs::write(&msg, "subject\n").unwrap();
Command::new(&hook)
.arg(&msg)
.current_dir(dir.path())
.status()
.unwrap();
assert!(
std::fs::read_to_string(&msg)
.unwrap()
.contains("Signed-off-by:"),
"sign-off must be added once opted in"
);
}
#[test]
#[serial_test::serial]
fn install_hook_dispatcher_refuses_to_take_a_local_hooks_path_it_does_not_own() {
let dir = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init", "-q"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["-C", dir.path().to_str().unwrap()])
.args(["config", "core.hooksPath", ".husky"])
.output()
.unwrap();
let err = {
let _cwd = CwdGuard::change_to(dir.path());
install_hook_dispatcher(false).unwrap_err().to_string()
};
assert!(err.contains(".husky"), "names the path it refused: {err}");
let out = Command::new("git")
.args(["-C", dir.path().to_str().unwrap()])
.args(["config", "--local", "core.hooksPath"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), ".husky");
}
#[test]
fn commit_msg_hook_chains_before_adding_signed_by_did() {
let chain_pos = COMMIT_MSG_HOOK.find("repo_hook=").unwrap();
let did_pos = COMMIT_MSG_HOOK
.find("DID=$(git config did-git-sign.key")
.unwrap();
assert!(chain_pos < did_pos);
assert!(COMMIT_MSG_HOOK.contains("$git_dir/hooks/commit-msg"));
assert!(COMMIT_MSG_HOOK.contains("Signed-by-DID: $DID"));
}
#[test]
fn test_different_keys_produce_different_ssh_strings() {
let key_a = [0x00; 32];
let key_b = [0xFF; 32];
assert_ne!(ssh_public_key_string(&key_a), ssh_public_key_string(&key_b));
}
struct CwdGuard {
original: std::path::PathBuf,
}
impl CwdGuard {
fn change_to(path: &std::path::Path) -> Self {
let original = std::env::current_dir().unwrap();
std::env::set_current_dir(path).unwrap();
CwdGuard { original }
}
}
impl Drop for CwdGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.original);
}
}
#[test]
#[serial_test::serial]
fn setup_git_writes_did_to_git_config_key() {
let dir = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
let original_cwd = std::env::current_dir().unwrap();
{
let _cwd = CwdGuard::change_to(dir.path());
let config_path = dir.path().join(".did-git-sign.json");
let cfg = SigningConfig {
did_key_id: "did:webvh:test#key-0".to_string(),
user_name: None,
};
setup_git(&config_path, &cfg, false).unwrap();
}
assert_eq!(
std::env::current_dir().unwrap(),
original_cwd,
"CwdGuard must restore the original directory on drop"
);
let out = std::process::Command::new("git")
.args([
"-C",
dir.path().to_str().unwrap(),
"config",
"--local",
"did-git-sign.key",
])
.output()
.unwrap();
assert!(
out.status.success(),
"did-git-sign.key must be set by setup_git"
);
assert_eq!(
String::from_utf8_lossy(&out.stdout).trim(),
"did:webvh:test#key-0",
);
let email_out = std::process::Command::new("git")
.args([
"-C",
dir.path().to_str().unwrap(),
"config",
"--local",
"user.email",
])
.output()
.unwrap();
if email_out.status.success() {
let email = String::from_utf8_lossy(&email_out.stdout)
.trim()
.to_string();
assert!(
!email.starts_with("did:"),
"user.email must not be set to a DID (got {email})"
);
}
}
}