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/push_options.rs

//! Gate 5, what a client may say with `git push -o …`.
//!
//! # Why this gate exists at all
//!
//! `receive-pack` **advertised** `push-options`, **parsed** the section, and
//! then threw it away: for the whole of v1 the only consumer anywhere in the
//! tree was `.len()` in one `tracing` field. A client that ran
//! `git push -o ci.skip` was told on the wire that the server understood push
//! options, and the option went nowhere. Advertising a capability you discard
//! is a wrong answer on the wire, not a missing feature — the honest choices
//! were to stop advertising it or to honour it, and this is the second.
//!
//! Stock git honours push options by handing them to `pre-receive`. gunnar has
//! no hooks, no shell, and no `fork`; the in-process gates are what
//! replaces them. So "honoured" means: the options reach
//! [`PushContext::push_options`](crate::PushContext::push_options), and a
//! configured gate can refuse on them.
//!
//! # What it decides
//!
//! Two rules, both off unless configured:
//!
//! * **`allowed`** — if non-empty, every option the client sent must match one
//!   of these patterns. An option nobody has heard of is refused *loudly*
//!   rather than accepted and ignored, which is the exact failure this gate was
//!   written out of.
//! * **`required`** — every one of these patterns must be matched by some
//!   option the client sent. This is the `-o ticket=NORD-1` shape: a push that
//!   does not name why it is happening does not land.
//!
//! Both are [`RefPattern`] globs, the same matcher the ref-name and protection
//! gates use (one implementation) — `ci.*`, `reviewer=*`, `ticket=NORD-???`.
//!
//! # The undecidable case
//!
//! If the context carries **no** push options at all — `None`, meaning nobody
//! wired them through rather than "the client sent none" — a configured gate
//! **refuses** and says why. The gate rule: a gate that cannot see what it decides
//! on does not decide, it rejects. Without this the gate would be satisfied by
//! its own plumbing being missing, which is how it got here.

use crate::config::PushOptionPolicy;
use crate::context::PushContext;
use crate::error::Result;
use crate::{Gate, Verdict};

/// Enforces which `git push -o …` options are accepted, and which are demanded.
#[derive(Clone, Debug)]
pub struct PushOptionsGate {
    policy: PushOptionPolicy,
}

impl PushOptionsGate {
    /// A gate over `policy`.
    pub fn new(policy: PushOptionPolicy) -> Self {
        PushOptionsGate { policy }
    }

    /// The policy this gate enforces.
    pub fn policy(&self) -> &PushOptionPolicy {
        &self.policy
    }
}

impl Gate for PushOptionsGate {
    fn name(&self) -> &str {
        "push_options"
    }

    fn check(&self, ctx: &PushContext<'_>) -> Result<Verdict> {
        // Not `unwrap_or(&[])`. An absent slice is "receive-pack never told
        // me", and answering it with the empty set would let an allowlist find
        // nothing to object to and a `required` rule be silently unmeetable.
        let Some(options) = ctx.push_options else {
            return Ok(Verdict::reject(
                "a push-option policy is configured but this push carried no push-option \
                 report, so the policy could not be applied. gunnar refuses rather than \
                 deciding on what it cannot see"
                    .to_string(),
            ));
        };

        if !self.policy.allowed.is_empty() {
            for option in options {
                if !self
                    .policy
                    .allowed
                    .iter()
                    .any(|p| p.matches(option.as_bytes()))
                {
                    return Ok(Verdict::reject(format!(
                        "push option {option:?} is not allowed here; this repository accepts {}",
                        self.policy
                            .allowed
                            .iter()
                            .map(|p| format!("`{p}`"))
                            .collect::<Vec<_>>()
                            .join(", ")
                    )));
                }
            }
        }

        for want in &self.policy.required {
            if !options.iter().any(|o| want.matches(o.as_bytes())) {
                return Ok(Verdict::reject(format!(
                    "this repository requires a push option matching `{want}`; \
                     {} were sent",
                    if options.is_empty() {
                        "none".to_string()
                    } else {
                        options
                            .iter()
                            .map(|o| format!("{o:?}"))
                            .collect::<Vec<_>>()
                            .join(", ")
                    }
                )));
            }
        }

        Ok(Verdict::Allow)
    }
}