a73x

src/server/governance/hook.rs

Ref:   Size: 16.5 KiB   History

//! The server-managed `update` hook.
//!
//! Authorization happens at two moments, because the server learns the
//! repository and the verb before it learns which refs are being written.
//! Dispatch answers "may this principal push here at all"; this hook answers
//! "may it write *this* ref", once per ref, which is what makes
//! `RW refs/collab/ = @agents` mean anything.
//!
//! The hook is the same binary as the server, re-invoked through a two-line
//! shell script. It learns who is pushing from the environment
//! `git-receive-pack` was spawned with, and it re-reads `settings.git` itself
//! rather than trusting anything the parent passed about permissions — the
//! parent passes an identity, never a decision.
//!
//! It also validates pushes to `settings` itself, and that part runs even on a
//! server with no governance yet. Otherwise the first config push would be the
//! one push nothing checks, and a typo in it would close the server with no
//! way back in short of `kubectl exec`.

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

use super::conf::{Access, Subject};
use super::{creator_of, load, validate_settings_tree, GovernanceState, SETTINGS_REPO};

/// Environment the server sets on `git-receive-pack`, and which the hook reads
/// back. Named rather than inherited so that a hook running outside the server
/// — by hand, say — fails closed instead of guessing.
pub const ENV_PRINCIPAL: &str = "GIT_COLLAB_PRINCIPAL";
/// Set only when the pushing session is a delegate certificate; its value is
/// the cert's key ID. Presence is what puts the collab-refs ceiling in force.
pub const ENV_DELEGATE: &str = "GIT_COLLAB_DELEGATE";
pub const ENV_REPOS_DIR: &str = "GIT_COLLAB_REPOS_DIR";
pub const ENV_REPO: &str = "GIT_COLLAB_REPO";
pub const ENV_REPO_PATH: &str = "GIT_COLLAB_REPO_PATH";

const HOOK_NAME: &str = "update";

/// The hooks directory for a repository, bare or not.
fn hooks_dir(repo_path: &Path, bare: bool) -> PathBuf {
    if bare {
        repo_path.join("hooks")
    } else {
        repo_path.join(".git").join("hooks")
    }
}

/// Install (or refresh) the `update` hook in a repository.
///
/// Written unconditionally rather than only when missing, so a hook cannot
/// drift from the binary that installs it, and so an operator who deleted it
/// gets it back on the next push rather than silently losing enforcement.
pub fn install(repo_path: &Path, bare: bool) -> Result<(), String> {
    let exe = std::env::current_exe().map_err(|e| format!("cannot locate own binary: {e}"))?;
    let exe = exe.to_str().ok_or("own binary path is not UTF-8")?;
    // The path is interpolated into a shell script, so a quote in it would
    // break out of the quoting. Refuse rather than emit a broken hook.
    if exe.contains('\'') {
        return Err(format!("own binary path contains a quote: {exe}"));
    }

    let dir = hooks_dir(repo_path, bare);
    std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
    let path = dir.join(HOOK_NAME);
    let script = format!(
        "#!/bin/sh\n\
         # Installed and overwritten by git-collab-server. Edits will be lost.\n\
         exec '{exe}' --governance-hook \"$1\" \"$2\" \"$3\"\n"
    );

    // Written through a temporary file and renamed into place. Two concurrent
    // pushes both refresh the hook, and a truncate-then-write would leave a
    // window in which the other push execs a half-written script — which git
    // would report as a failed hook, i.e. a spurious rejection.
    let temp = dir.join(format!(".{HOOK_NAME}.{}", std::process::id()));
    std::fs::write(&temp, script).map_err(|e| format!("{}: {e}", temp.display()))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        // Set before the rename, so the file is never visible non-executable.
        std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o755))
            .map_err(|e| format!("{}: {e}", temp.display()))?;
    }
    std::fs::rename(&temp, &path).map_err(|e| {
        let _ = std::fs::remove_file(&temp);
        format!("{}: {e}", path.display())
    })?;
    Ok(())
}

/// Which access a ref update needs.
///
/// Deleting or rewinding is `+`; creating or fast-forwarding is `W`. Anything
/// this cannot determine — a tag object where a commit was expected, an
/// unreadable object — counts as a rewind, because guessing "fast-forward"
/// would be guessing in the permissive direction.
fn required_access(repo: &git2::Repository, old: git2::Oid, new: git2::Oid) -> Access {
    if new.is_zero() {
        return Access::Rewind;
    }
    if old.is_zero() {
        return Access::Write;
    }
    match repo.graph_descendant_of(new, old) {
        Ok(true) => Access::Write,
        Ok(false) => Access::Rewind,
        Err(_) => Access::Rewind,
    }
}

fn env(name: &str) -> Result<String, String> {
    std::env::var(name).map_err(|_| {
        format!("{name} is not set; this hook is only meaningful under git-collab-server")
    })
}

/// The hook body. `Err` rejects the ref update, and its text is what the
/// pushing client sees on its `remote:` lines.
pub fn run(refname: &str, old: &str, new: &str) -> Result<(), String> {
    let repos_dir = PathBuf::from(env(ENV_REPOS_DIR)?);
    let repo_key = env(ENV_REPO)?;
    let repo_path = PathBuf::from(env(ENV_REPO_PATH)?);
    // Empty is legitimate: an ungoverned server has no names to pass.
    let principal = std::env::var(ENV_PRINCIPAL).unwrap_or_default();

    // The delegate ceiling, per ref: the half of `delegate::permits`'s Push
    // answer that only receive-pack can see. This process is spawned by git,
    // not by the session, so it re-states the policy rather than calling it.
    //
    // Checked ahead of and independent of governance state: ENV_DELEGATE is
    // set only by the server, only for a delegate session, so its presence
    // alone is a sufficient trigger. Nesting this
    // inside `GovernanceState::Active` would let a settings repository that
    // goes briefly unreadable-as-Absent between the session's regime check
    // and this hook's own re-read skip the ceiling entirely — hard-coded
    // rather than configured, so no line in access.conf can widen it either.
    //
    // Assumption written down: `starts_with("refs/collab/")` is sufficient to
    // confine a delegate only because a refname containing `..` — e.g.
    // `refs/collab/../heads/main` — can never reach this comparison in the
    // first place. `git-receive-pack` validates every pushed refname with
    // `check_refname_format` (`ref_name_is_safe` further down that call
    // chain) before invoking the update hook at all, so a path-traversing
    // refname is rejected upstream of this code, not by it.
    let delegate = std::env::var(ENV_DELEGATE).ok().filter(|v| !v.is_empty());
    if let Some(key_id) = &delegate {
        if !refname.starts_with("refs/collab/") {
            return Err(format!(
                "delegate {key_id} of {principal} may only write refs/collab/*, \
                 not {refname}"
            ));
        }
    }

    let old = parse_oid(old)?;
    let new = parse_oid(new)?;

    // Opened from the environment so the objects still in receive-pack's
    // quarantine are visible: during a hook they live in GIT_OBJECT_DIRECTORY
    // with the repository's real object store as an alternate, and neither is
    // reachable by opening the path directly.
    let repo = git2::Repository::open_from_env()
        .map_err(|e| format!("cannot open the receiving repository: {e}"))?;

    let state = load(&repos_dir);

    if let GovernanceState::Active(governance) = &state {
        if principal.is_empty() {
            return Err("no authenticated principal on this push".to_string());
        }

        let creator = creator_of(&repo_path);
        let subject = Subject::with_creator(&principal, creator.as_deref());
        let access = required_access(&repo, old, new);
        if !governance
            .conf
            .allows_ref(&repo_key, &subject, refname, access)
        {
            return Err(format!(
                "{principal} may not {} {refname} in {repo_key}",
                describe(access)
            ));
        }
    }
    if let GovernanceState::Unreadable(reason) = &state {
        return Err(format!("the settings repository is unreadable: {reason}"));
    }

    if repo_key == SETTINGS_REPO {
        validate_settings_push(&repo, refname, new)?;
    }

    Ok(())
}

fn describe(access: Access) -> &'static str {
    match access {
        Access::Read => "read",
        Access::Write => "write",
        Access::Rewind => "rewind or delete",
        Access::Create => "create",
    }
}

fn parse_oid(text: &str) -> Result<git2::Oid, String> {
    git2::Oid::from_str(text).map_err(|e| format!("bad object id {text:?}: {e}"))
}

/// Reject a push to `settings` that would leave the server with a config it
/// cannot use. This is what removes the malformed-config failure mode: the
/// live config is only ever one that passed this.
fn validate_settings_push(
    repo: &git2::Repository,
    refname: &str,
    new: git2::Oid,
) -> Result<(), String> {
    // Only branches can become the live config; anything else in this
    // repository is ordinary collaboration data.
    if !refname.starts_with("refs/heads/") {
        return Ok(());
    }

    // The name `settings` alone does not make a repository the governance
    // repository — `conf/access.conf` does. Without this, an ungoverned server
    // that happens to host an unrelated repo called `settings` would start
    // rejecting its pushes as invalid configuration.
    let live_is_config = live_tree(repo).is_some_and(|tree| has_access_conf(&tree));

    if new.is_zero() {
        return if live_is_config && live_branch(repo).as_deref() == Some(refname) {
            Err(format!(
                "refusing to delete {refname}: it is the live configuration of the \
                 {SETTINGS_REPO} repository"
            ))
        } else {
            Ok(())
        };
    }

    let commit = repo
        .find_commit(new)
        .map_err(|e| format!("{refname}: cannot read the pushed commit: {e}"))?;
    let tree = commit
        .tree()
        .map_err(|e| format!("{refname}: cannot read the pushed tree: {e}"))?;

    // A roster with no rules is refused, whether it arrives by pushing
    // `keydir/` first or by deleting `conf/access.conf` and leaving the roster
    // standing. The resulting tree is what matters, not the diff: both leave a
    // repository that governs nothing while naming everyone who would be
    // governed, and an ungoverned server publishes it like any other.
    if let Err(reason) = super::check_roster_has_rules(&tree) {
        return Err(format!("{refname}: {reason}"));
    }

    if !live_is_config && !has_access_conf(&tree) {
        return Ok(());
    }

    let governance = validate_settings_tree(repo, &tree, refname)
        .map_err(|e| format!("rejecting this configuration; the previous one stays live\n  {e}"))?;

    // Warnings, not errors: this push still lands. Printed here (rather than
    // returned as part of the Err path above) because a valid config that
    // merely looks suspicious should reach the operator without blocking
    // them — stderr from the update hook is relayed to the pusher as
    // `remote:` lines regardless of the hook's exit status.
    for warning in super::cadir_warnings(&governance) {
        eprintln!("warning: {warning}");
    }

    Ok(())
}

fn has_access_conf(tree: &git2::Tree<'_>) -> bool {
    tree.get_path(Path::new(super::ACCESS_CONF_PATH)).is_ok()
}

/// The branch HEAD points at, whether or not it currently resolves.
fn live_branch(repo: &git2::Repository) -> Option<String> {
    repo.find_reference("HEAD")
        .ok()?
        .symbolic_target()
        .map(str::to_string)
}

fn live_tree(repo: &git2::Repository) -> Option<git2::Tree<'_>> {
    repo.head().ok()?.peel_to_commit().ok()?.tree().ok()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::process::Command;
    use tempfile::TempDir;

    fn init_bare(path: &Path) -> git2::Repository {
        git2::Repository::init_bare(path).unwrap()
    }

    #[test]
    fn hooks_live_beside_the_object_store_for_bare_repos() {
        assert_eq!(
            hooks_dir(Path::new("/srv/git/t.git"), true),
            PathBuf::from("/srv/git/t.git/hooks")
        );
        assert_eq!(
            hooks_dir(Path::new("/srv/git/t"), false),
            PathBuf::from("/srv/git/t/.git/hooks")
        );
    }

    #[test]
    fn installed_hook_is_executable_and_names_this_binary() {
        let tmp = TempDir::new().unwrap();
        let repo = tmp.path().join("t.git");
        init_bare(&repo);
        install(&repo, true).unwrap();

        let path = repo.join("hooks").join(HOOK_NAME);
        let script = std::fs::read_to_string(&path).unwrap();
        assert!(script.starts_with("#!/bin/sh"), "got {script}");
        assert!(script.contains("--governance-hook"), "got {script}");
        assert!(
            script.contains(std::env::current_exe().unwrap().to_str().unwrap()),
            "got {script}"
        );

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
            assert_eq!(mode & 0o111, 0o111, "hook must be executable");
        }
    }

    #[test]
    fn installing_twice_refreshes_rather_than_appends() {
        let tmp = TempDir::new().unwrap();
        let repo = tmp.path().join("t.git");
        init_bare(&repo);
        install(&repo, true).unwrap();
        let first = std::fs::read_to_string(repo.join("hooks").join(HOOK_NAME)).unwrap();
        install(&repo, true).unwrap();
        let second = std::fs::read_to_string(repo.join("hooks").join(HOOK_NAME)).unwrap();
        assert_eq!(first, second);
    }

    #[test]
    fn install_replaces_a_hook_that_was_tampered_with() {
        let tmp = TempDir::new().unwrap();
        let repo = tmp.path().join("t.git");
        init_bare(&repo);
        std::fs::create_dir_all(repo.join("hooks")).unwrap();
        std::fs::write(repo.join("hooks").join(HOOK_NAME), "#!/bin/sh\nexit 0\n").unwrap();

        install(&repo, true).unwrap();

        let script = std::fs::read_to_string(repo.join("hooks").join(HOOK_NAME)).unwrap();
        assert!(script.contains("--governance-hook"), "got {script}");
    }

    /// A repository with commits, so ancestry questions have an answer.
    fn repo_with_two_commits() -> (TempDir, git2::Repository, git2::Oid, git2::Oid) {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("work");
        std::fs::create_dir_all(&path).unwrap();
        for args in [
            vec!["init", "-q", "-b", "main"],
            vec!["config", "user.email", "t@example.com"],
            vec!["config", "user.name", "T"],
        ] {
            Command::new("git")
                .args(&args)
                .current_dir(&path)
                .output()
                .unwrap();
        }
        std::fs::write(path.join("a"), "1").unwrap();
        Command::new("git")
            .args(["add", "-A"])
            .current_dir(&path)
            .output()
            .unwrap();
        Command::new("git")
            .args(["commit", "-qm", "one"])
            .current_dir(&path)
            .output()
            .unwrap();
        let repo = git2::Repository::open(&path).unwrap();
        let first = repo.head().unwrap().peel_to_commit().unwrap().id();

        std::fs::write(path.join("a"), "2").unwrap();
        Command::new("git")
            .args(["commit", "-qam", "two"])
            .current_dir(&path)
            .output()
            .unwrap();
        let repo = git2::Repository::open(&path).unwrap();
        let second = repo.head().unwrap().peel_to_commit().unwrap().id();

        (tmp, repo, first, second)
    }

    #[test]
    fn creating_and_fast_forwarding_need_write_rewinding_and_deleting_need_plus() {
        let (_tmp, repo, first, second) = repo_with_two_commits();
        let zero = git2::Oid::zero();

        assert_eq!(required_access(&repo, zero, first), Access::Write);
        assert_eq!(required_access(&repo, first, second), Access::Write);
        assert_eq!(required_access(&repo, second, first), Access::Rewind);
        assert_eq!(required_access(&repo, first, zero), Access::Rewind);
    }

    #[test]
    fn an_unreadable_ancestry_is_treated_as_a_rewind_not_a_fast_forward() {
        let (_tmp, repo, first, _second) = repo_with_two_commits();
        let missing = git2::Oid::from_str("0123456789012345678901234567890123456789").unwrap();
        assert_eq!(required_access(&repo, first, missing), Access::Rewind);
    }
}