Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-policy/src/gates/protection.rs

//! Gate 1: branch protection.
//!
//! Per-ref-pattern: no force-push, no delete, required signature, allowed
//! principals.
//!
//! # The force-push check is an ancestry walk, not a comparison
//!
//! There are four ways to write this check that pass every test built out of a
//! linear three-commit fixture and are all wrong:
//!
//! | wrong check | what it actually detects |
//! |---|---|
//! | `old != new` | that the reference moved |
//! | comparing hex strings | the same thing, more slowly |
//! | `new` is among the pushed commits | that the push contained the tip |
//! | first-parent walk from `new` | descent **on the mainline only**; a legitimate merge whose old tip sits on the second parent reads as a force-push |
//!
//! The honest check is: is the old tip reachable from the new tip, through *any*
//! parent edge. That is [`crate::ancestry::is_descendant_of`], and the fixture it
//! is proved against contains a merge whose relevant history lies exclusively on
//! a second-parent path.
//!
//! # An undecidable check refuses
//!
//! If the walk runs out of budget or hits a missing object, this gate rejects
//! with the reason rather than allowing. "I could not tell" is not "it is fine".

use std::collections::HashSet;

use crate::config::ProtectionRule;
use crate::context::{PushContext, RefUpdate, UpdateKind};
use crate::error::Result;
use crate::{ancestry, Gate, Verdict};

/// Enforces per-pattern protection rules.
#[derive(Clone, Debug)]
pub struct BranchProtectionGate {
    rules: Vec<ProtectionRule>,
}

impl BranchProtectionGate {
    /// A gate over `rules`, first match winning.
    pub fn new(rules: Vec<ProtectionRule>) -> Self {
        BranchProtectionGate { rules }
    }

    /// The rules this gate enforces.
    pub fn rules(&self) -> &[ProtectionRule] {
        &self.rules
    }

    /// The first rule covering `update`, if any.
    fn rule_for(&self, update: &RefUpdate) -> Option<&ProtectionRule> {
        self.rules
            .iter()
            .find(|r| r.pattern.matches_ref(&update.name))
    }
}

impl Gate for BranchProtectionGate {
    fn name(&self) -> &str {
        "branch-protection"
    }

    fn check(&self, ctx: &PushContext<'_>) -> Result<Verdict> {
        let mut checked_signatures: HashSet<gix_hash::ObjectId> = HashSet::new();

        for update in ctx.updates {
            let Some(rule) = self.rule_for(update) else {
                continue;
            };
            let name = &update.name;

            if let Some(allowed) = &rule.allowed_principals {
                if !allowed.iter().any(|id| id == &ctx.principal.id) {
                    return Ok(Verdict::reject_ref(
                        name.clone(),
                        format!(
                            "{name} is protected by {} and {} is not among its allowed principals",
                            rule.pattern, ctx.principal.id
                        ),
                    ));
                }
            }

            match update.kind() {
                UpdateKind::Noop => {
                    return Ok(Verdict::reject_ref(
                        name.clone(),
                        format!("{name} has neither an old nor a new value"),
                    ));
                }
                UpdateKind::Delete => {
                    if !rule.allow_delete {
                        return Ok(Verdict::reject_ref(
                            name.clone(),
                            format!(
                                "{name} is protected by {} and may not be deleted",
                                rule.pattern
                            ),
                        ));
                    }
                }
                UpdateKind::Create => {
                    if !rule.allow_create {
                        return Ok(Verdict::reject_ref(
                            name.clone(),
                            format!(
                                "{name} is protected by {} and may not be created",
                                rule.pattern
                            ),
                        ));
                    }
                }
                UpdateKind::Update => {
                    let (old, new) = (
                        update.old.expect("an update has an old value"),
                        update.new.expect("an update has a new value"),
                    );
                    if !rule.allow_force_push {
                        match ancestry::is_descendant_of(
                            ctx.objects,
                            &new,
                            &old,
                            rule.ancestry_budget,
                        ) {
                            Ok(true) => {}
                            Ok(false) => {
                                return Ok(Verdict::reject_ref(
                                    name.clone(),
                                    format!(
                                        "non-fast-forward: {name} is protected by {} and {new} \
                                         does not descend from {old}",
                                        rule.pattern
                                    ),
                                ));
                            }
                            Err(err) => {
                                // Undecided is not allowed. Reject, and say what
                                // went wrong, rather than letting a walk that ran
                                // out of room look like a fast-forward.
                                tracing::warn!(
                                    ref_name = %name,
                                    error = %err,
                                    "ancestry check could not be completed; refusing the update"
                                );
                                return Ok(Verdict::reject_ref(
                                    name.clone(),
                                    format!(
                                        "{name} is protected by {} and its ancestry could not be \
                                         established: {err}",
                                        rule.pattern
                                    ),
                                ));
                            }
                        }
                    }
                }
            }

            if rule.require_signature {
                let verdict = super::verify_update_signatures(
                    ctx,
                    update,
                    crate::config::SignerScope::AnyRegisteredKey,
                    crate::signature::GIT_NAMESPACE,
                    rule.ancestry_budget,
                    &mut checked_signatures,
                    self.name(),
                )?;
                if let Verdict::Reject(rejection) = verdict {
                    return Ok(Verdict::reject_ref(
                        name.clone(),
                        format!(
                            "{name} is protected by {} and requires signed commits: {}",
                            rule.pattern, rejection.reason
                        ),
                    ));
                }
            }
        }

        Ok(Verdict::Allow)
    }
}