did-git-sign 0.15.0

Git commit signing proxy using DID Ed25519 keys via VTA
//! Additive git integration: did-git-sign signs only where it is enabled.
//!
//! `init` used to write its signing settings straight into the user's global
//! (or a repository's) git config, replacing whatever signing setup was there.
//! Now every setting lives in a file did-git-sign owns, one per identity:
//!
//! ```text
//! <config dir>/did-git-sign/gitconfig/<name>.gitconfig
//! ```
//!
//! and a repository opts in with a single `include.path` line in its
//! `.git/config` ([`enable_repo`]), or a directory of repositories with a
//! single `includeIf "gitdir:…"` line in the global config ([`enable_dir`]).
//! Nothing else in anyone's git config is written, so turning it off is
//! removing that one line, and the previous setup is untouched throughout.

use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Context, Result, bail};

use crate::config::SigningConfig;

/// The name of the include file for an identity `init` set up without
/// `--profile`.
pub const DEFAULT_NAME: &str = "default";

/// `<config dir>/did-git-sign`, where every file did-git-sign owns lives.
pub fn data_dir() -> Result<PathBuf> {
    Ok(SigningConfig::default_global_path()?
        .parent()
        .context("global config path has no parent directory")?
        .to_path_buf())
}

/// The hook directory the include files point `core.hooksPath` at.
pub fn hooks_dir() -> Result<PathBuf> {
    Ok(data_dir()?.join("hooks"))
}

/// The `allowed_signers` file the include files point git at.
pub fn allowed_signers_path() -> Result<PathBuf> {
    Ok(data_dir()?.join("allowed_signers"))
}

/// The directory holding the include files.
pub fn include_dir() -> Result<PathBuf> {
    Ok(data_dir()?.join("gitconfig"))
}

/// The include file for the identity called `name` (a profile name, or
/// [`DEFAULT_NAME`]).
pub fn include_path(name: &str) -> Result<PathBuf> {
    Ok(include_dir()?.join(format!("{name}.gitconfig")))
}

/// Write the include file for `name`, signing as `did_key_id`, replacing any
/// earlier one. Every setting a repository needs to sign with did-git-sign is
/// in it; nothing is written anywhere else.
pub fn write_include(name: &str, did_key_id: &str) -> Result<PathBuf> {
    let path = include_path(name)?;
    write_include_at(&path, did_key_id)?;
    Ok(path)
}

/// [`write_include`] to an explicit path.
pub fn write_include_at(path: &Path, did_key_id: &str) -> Result<()> {
    let dir = path.parent().context("include path has no parent")?;
    std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
    std::fs::write(
        path,
        "# Written by did-git-sign. A repository signs with the identity below only\n\
         # when it includes this file: `did-git-sign enable` adds the include,\n\
         # `did-git-sign disable` removes it. Regenerated by `did-git-sign init`.\n",
    )
    .with_context(|| format!("failed to write {}", path.display()))?;

    let config_json = SigningConfig::default_global_path()?;
    let config_json = utf8(&config_json)?;
    let hooks = hooks_dir()?;
    let signers = allowed_signers_path()?;
    for (key, value) in [
        ("gpg.format", "ssh"),
        // Git calls `<program> -Y sign -f <user.signingKey> -n git <file>`.
        ("gpg.ssh.program", "did-git-sign"),
        ("user.signingKey", config_json),
        ("gpg.ssh.defaultKeyFile", config_json),
        ("gpg.ssh.allowedSignersFile", utf8(&signers)?),
        ("commit.gpgsign", "true"),
        // The commit-msg hook writes the Signed-by-DID trailer from
        // did-git-sign.key; the other hooks delegate to .git/hooks.
        ("core.hooksPath", utf8(&hooks)?),
        ("did-git-sign.key", did_key_id),
    ] {
        git(&["config", "-f", utf8(path)?, key, value])
            .with_context(|| format!("failed to write {key} to {}", path.display()))?;
    }
    Ok(())
}

/// The identity an include file signs as, if it is readable.
pub fn include_identity(path: &Path) -> Option<String> {
    git_out(&[
        "config",
        "-f",
        utf8(path).ok()?,
        "--get",
        "did-git-sign.key",
    ])
}

/// Whether `value` (an `include.path` or `includeIf.*.path`) names one of
/// did-git-sign's include files.
pub fn is_our_include(value: &str) -> bool {
    let Ok(dir) = include_dir() else { return false };
    let value = expand_home(value.trim());
    Path::new(&value).starts_with(&dir)
}

/// The did-git-sign include file this repository's own config includes, if
/// any. Only the repository's `.git/config` is consulted.
pub fn repo_include() -> Option<PathBuf> {
    let all = git_out(&["config", "--local", "--get-all", "include.path"])?;
    all.lines()
        .rev()
        .find(|v| is_our_include(v))
        .map(|v| PathBuf::from(expand_home(v.trim())))
}

/// Make the current repository sign with the identity in `include`: remove
/// any did-git-sign include it already has, then add this one. That single
/// line in `.git/config` is the whole change.
pub fn enable_repo(include: &Path) -> Result<()> {
    if !inside_work_tree() {
        bail!("not inside a git repository; run this in one, or use --dir");
    }
    refuse_foreign_hooks_path("--local")?;
    refuse_foreign_hooks_path("--global")?;
    remove_repo_includes()?;
    git(&["config", "--local", "--add", "include.path", utf8(include)?])?;
    Ok(())
}

/// Stop the current repository signing with did-git-sign: remove the include
/// lines that name did-git-sign's files, and nothing else. Returns whether
/// there was one.
pub fn disable_repo() -> Result<bool> {
    if !inside_work_tree() {
        bail!("not inside a git repository; run this in one, or use --dir");
    }
    remove_repo_includes()
}

/// Make every repository under `dir` sign with the identity in `include`: one
/// `includeIf "gitdir:<dir>/"` line in the global git config. Returns the key
/// it set.
pub fn enable_dir(dir: &str, include: &Path) -> Result<String> {
    refuse_foreign_hooks_path("--global")?;
    let key = include_if_key(dir);
    git(&["config", "--global", &key, utf8(include)?])?;
    Ok(key)
}

/// Remove the `includeIf "gitdir:<dir>/"` line [`enable_dir`] added, if it
/// names a did-git-sign include. Returns whether there was one.
pub fn disable_dir(dir: &str) -> Result<bool> {
    let key = include_if_key(dir);
    match git_out(&["config", "--global", "--get", &key]) {
        Some(v) if is_our_include(&v) => {
            git(&["config", "--global", "--unset", &key])?;
            Ok(true)
        }
        Some(v) => bail!("{key} is set to '{v}', which is not did-git-sign's; leaving it"),
        None => Ok(false),
    }
}

/// `includeIf.gitdir:<dir>/.path`, with the trailing slash that makes git
/// match every repository beneath `dir`.
pub fn include_if_key(dir: &str) -> String {
    let dir = dir.trim();
    let dir = if dir.ends_with('/') {
        dir.to_string()
    } else {
        format!("{dir}/")
    };
    format!("includeIf.gitdir:{dir}.path")
}

/// Remove every global `includeIf.*.path` and `include.path` entry that names
/// `include`, and the current repository's. Used when an identity is
/// uninstalled. Returns how many lines were removed.
pub fn remove_references(include: &Path) -> Result<usize> {
    let target = include.to_path_buf();
    let mut removed = 0;
    // Global `includeIf "gitdir:…".path` and `include.path` entries.
    if let Some(list) = git_out(&[
        "config",
        "--global",
        "--get-regexp",
        r"^include(if\..*)?\.path$",
    ]) {
        for line in list.lines() {
            let Some((key, value)) = line.split_once(' ') else {
                continue;
            };
            if Path::new(&expand_home(value.trim())) == target {
                let pattern = format!("^{}$", regex_escape(value.trim()));
                if git(&["config", "--global", "--unset-all", key, &pattern]).is_ok() {
                    removed += 1;
                }
            }
        }
    }
    // The current repository's own include, if it names this file.
    if inside_work_tree()
        && let Some(list) = git_out(&["config", "--local", "--get-all", "include.path"])
    {
        for value in list.lines() {
            if Path::new(&expand_home(value.trim())) == target {
                let pattern = format!("^{}$", regex_escape(value.trim()));
                if git(&["config", "--local", "--unset-all", "include.path", &pattern]).is_ok() {
                    removed += 1;
                }
            }
        }
    }
    Ok(removed)
}

/// Remove every `include.path` line in `.git/config` that names a
/// did-git-sign include file. Other includes are left in place.
fn remove_repo_includes() -> Result<bool> {
    let Some(all) = git_out(&["config", "--local", "--get-all", "include.path"]) else {
        return Ok(false);
    };
    let mut removed = false;
    for value in all.lines().filter(|v| is_our_include(v)) {
        let pattern = format!("^{}$", regex_escape(value.trim()));
        git(&["config", "--local", "--unset-all", "include.path", &pattern])?;
        removed = true;
    }
    Ok(removed)
}

/// Refuse when `core.hooksPath` at `scope` belongs to another tool (husky,
/// lefthook, pre-commit). The include would replace it in the repositories it
/// applies to, and every hook that tool installed would stop running.
fn refuse_foreign_hooks_path(scope: &str) -> Result<()> {
    let ours = hooks_dir()?;
    if let Some(existing) = git_out(&["config", scope, "--get", "core.hooksPath"])
        && Path::new(&expand_home(existing.trim())) != ours
    {
        bail!(
            "{scope} core.hooksPath is set to '{existing}', which is not did-git-sign's. \
             Enabling would replace it and its hooks would stop running. Unset it, or add the \
             Signed-by-DID trailer logic to that directory's commit-msg hook by hand."
        );
    }
    Ok(())
}

fn inside_work_tree() -> bool {
    git_out(&["rev-parse", "--is-inside-work-tree"]).as_deref() == Some("true")
}

/// `~/…` as git expands it in `include.path`.
fn expand_home(value: &str) -> String {
    match (value.strip_prefix("~/"), dirs::home_dir()) {
        (Some(rest), Some(home)) => home.join(rest).to_string_lossy().into_owned(),
        _ => value.to_string(),
    }
}

/// Escape `s` for use as a POSIX extended regular expression that matches it
/// literally (git's `value-pattern`).
pub fn regex_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        if "\\^$.|?*+()[]{}".contains(c) {
            out.push('\\');
        }
        out.push(c);
    }
    out
}

fn utf8(p: &Path) -> Result<&str> {
    p.to_str()
        .with_context(|| format!("path is not valid UTF-8: {}", p.display()))
}

/// Run git, failing with its stderr.
fn git(args: &[&str]) -> Result<()> {
    let out = Command::new("git")
        .args(args)
        .output()
        .context("failed to run git")?;
    if !out.status.success() {
        bail!(
            "git {} failed: {}",
            args.join(" "),
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }
    Ok(())
}

/// Run git and return its trimmed stdout, or `None` on failure or empty
/// output.
fn git_out(args: &[&str]) -> Option<String> {
    let out = Command::new("git").args(args).output().ok()?;
    let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
    (out.status.success() && !v.is_empty()).then_some(v)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn include_if_key_always_ends_in_a_slash() {
        assert_eq!(include_if_key("~/devel"), "includeIf.gitdir:~/devel/.path");
        assert_eq!(include_if_key("~/devel/"), "includeIf.gitdir:~/devel/.path");
    }

    #[test]
    fn regex_escape_makes_a_path_literal() {
        assert_eq!(
            regex_escape("/a b/did-git-sign/x.gitconfig"),
            r"/a b/did-git-sign/x\.gitconfig"
        );
        assert_eq!(regex_escape("a+b(c)"), r"a\+b\(c\)");
    }

    /// Every value survives the round trip, the DID's `#key-N` included: in a
    /// git config file an unquoted `#` starts a comment.
    #[test]
    fn the_include_file_keeps_every_value_whole() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("bob.gitconfig");
        let did = "did:webvh:QmBob:example.com:bob#key-0";
        write_include_at(&path, did).unwrap();
        let get = |key: &str| {
            git_out(&["config", "-f", path.to_str().unwrap(), "--get", key]).unwrap_or_default()
        };
        assert_eq!(get("did-git-sign.key"), did);
        assert_eq!(include_identity(&path).as_deref(), Some(did));
        assert_eq!(get("gpg.ssh.program"), "did-git-sign");
        assert_eq!(get("gpg.format"), "ssh");
        assert_eq!(get("commit.gpgsign"), "true");
        assert_eq!(
            get("core.hooksPath"),
            hooks_dir().unwrap().to_str().unwrap()
        );
        // A rewrite replaces it rather than appending a second value.
        write_include_at(&path, "did:webvh:QmCarol:example.com:carol#key-1").unwrap();
        assert_eq!(
            get("did-git-sign.key"),
            "did:webvh:QmCarol:example.com:carol#key-1"
        );
    }

    #[test]
    fn only_paths_under_the_include_dir_are_ours() {
        let ours = include_path("bob").unwrap();
        assert!(is_our_include(ours.to_str().unwrap()));
        assert!(!is_our_include("/home/someone/.gitconfig-work"));
        assert!(!is_our_include("~/.config/git/other.inc"));
    }
}