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);
}
}