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