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