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

//! The pre-receive gate: what makes gunnar a forge rather than a bare
//! repository over SSH.
//!
//! This crate runs between *"the pack is indexed"* and *"the references are
//! applied"* (the receive-pack sequence, steps 4 → 5), and it can say no.
//!
//! ```text
//! receive-pack:  read command list → read pack → index → verify connectivity
//!                        │
//!                        ├─▶  gunnar-policy  ──▶ Allow      → apply the ref transaction
//!                        │                   └─▶ Reject(…)  → report-status, no refs move
//! ```
//!
//! # The gates
//!
//! | gate | refuses |
//! |---|---|
//! | [`BranchProtectionGate`] | a force-push, a delete, an unsigned commit or an unauthorised pusher on a protected ref |
//! | [`RefNameGate`] | a reference name the namespace has not sanctioned |
//! | [`SignedCommitsGate`] | a commit without a verifying `gpg.format=ssh` signature |
//! | [`LimitsGate`] | an oversized object or an absurd object count |
//! | [`PushOptionsGate`] | a `git push -o` option the repository does not accept, or a missing required one |
//! | [`AiTrailerGate`] | a commit new to the forge carrying a machine-attribution trailer — **on by default** |
//!
//! # Hooks are gates, not scripts
//!
//! There is no shell on this server and there will not be one. A `pre-receive`
//! hook is replaced by a [`PolicyConfig`] (a serde document naming gates and
//! their parameters) and by in-process [`Gate`] impls. An out-of-process hook,
//! if one is ever wanted, is a gRPC call to a gate service; never `fork`/`exec`.
//!
//! # Measure, never assert — harder here than anywhere else in gunnar
//!
//! > A gate that always returns `Allow` is indistinguishable from a working gate
//! > in any test that only pushes valid commits.
//!
//! That sentence is the specification's, and it describes the exact way this
//! crate would fail. Everything below is arranged around it:
//!
//! * **Every gate has a paired test.** One push that must be refused with a
//!   named reason, and one that must be allowed. Never one direction alone.
//! * **The harness is proved able to report red.** [`blind`] holds an
//!   always-allow stand-in, and `tests/blind.rs` runs the *same* crafted-bad-push
//!   fixtures through it and asserts the harness fails. If the assertions ever
//!   stop being able to fail, that test goes red first.
//! * **Undecidable is not allowed.** A missing object, an exhausted walk budget
//!   or an unmeasured pack size produces a rejection with the reason attached,
//!   never a pass. See [`error`].
//! * **The force-push check is a real ancestry walk** over a fixture containing a
//!   merge, because a first-parent walk and a full one are indistinguishable on
//!   a linear three-commit history. See [`ancestry`].
//! * **Signature verification is checked against stock `git`.** The tests drive
//!   `git` with `gpg.format=ssh` to produce genuinely signed commits, and verify
//!   them with this crate's pure-Rust path. gunnar never runs git; its tests do,
//!   because two implementations agreeing is evidence and one implementation
//!   agreeing with itself is not.
//!
//! # The no-OS rule
//!
//! `gunnar-policy` is an **S** crate. No filesystem, no shell, no subprocess, no
//! `gpg`, no `ssh-keygen`, no C. Signature verification is `ssh-key` and byte
//! handling, and everything else is the
//! [`GitServe`](gunnar_store::git_storage_trait::GitServe) trait — the
//! **reading** contract, reached through `gunnar-store`'s re-export so this
//! crate does not pin its own copy of `git-storage-trait`.

#![warn(missing_docs)]
#![warn(clippy::all)]
#![doc(
    html_logo_url = "https://gunnar.rs/download/gunnar.webp",
    html_favicon_url = "https://gunnar.rs/download/favicon.svg"
)]

pub mod ancestry;
pub mod blind;
pub mod config;
pub mod context;
pub mod error;
pub mod gates;
pub mod pattern;
pub mod signature;
pub mod trailer;

pub use config::{
    AiTrailerPolicy, LimitPolicy, MergePolicy, PolicyConfig, ProtectionRule, PushOptionPolicy,
    RefNamePolicy, SignaturePolicy, SignerScope,
};
pub use context::{
    KeyDirectory, Principal, PrincipalKind, PushContext, RefUpdate, RepoId, StaticKeyDirectory,
    UpdateKind,
};
pub use error::{Error, Result};
pub use gates::{
    check_pushed, AiTrailerGate, BranchProtectionGate, EncryptedGate, LimitsGate, PushOptionsGate,
    PushedObjects, RefNameGate, SignedCommitsGate,
};
/// A blob's shape as the encrypted-repository rule reads it (gunnar-crypt's).
pub use gunnar_crypt::policy::{Shape, MAX_SCHEME_FILE};
pub use pattern::RefPattern;
pub use signature::{SignatureCheck, SignatureProblem, VerifiedSignature};

/// What a gate decided.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
    /// Nothing to object to.
    Allow,
    /// This push, or one reference in it, is refused.
    Reject(Rejection),
}

/// Why a push was refused, and which reference it was about.
///
/// `ref_name` is `Some` whenever the refusal is attributable to one reference, so
/// `report-status-v2` can put the reason on the right line. It is `None` for
/// push-wide refusals such as an object-count ceiling.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rejection {
    /// The reference at fault, if the refusal is about one reference.
    ///
    /// A `String`, not a validated newtype. It was `gunnar_store::FullName`
    /// until 2026-08-10; that type went with `RefStore`, and `GitOps` — the one
    /// storage contract now — spells every reference name as a `String`
    /// (`RefRow::name`, `RefUpdate::name`). The *rules* survived as
    /// `gunnar_store::refname::validate`, which is where receive-pack applies
    /// them: a name reaching a gate has already been through it, and a gate is
    /// not the place to re-derive that.
    pub ref_name: Option<String>,
    /// A message fit to send back over the wire. It names what was refused and
    /// which rule refused it. A bare "rejected by policy" wastes the one round
    /// trip the pusher gets.
    pub reason: String,
}

impl Verdict {
    /// A push-wide refusal.
    pub fn reject(reason: impl Into<String>) -> Self {
        Verdict::Reject(Rejection {
            ref_name: None,
            reason: reason.into(),
        })
    }

    /// A refusal attributable to one reference.
    pub fn reject_ref(name: impl Into<String>, reason: impl Into<String>) -> Self {
        Verdict::Reject(Rejection {
            ref_name: Some(name.into()),
            reason: reason.into(),
        })
    }

    /// `true` if the gate allowed the push.
    pub fn is_allow(&self) -> bool {
        matches!(self, Verdict::Allow)
    }

    /// `true` if the gate refused the push.
    pub fn is_reject(&self) -> bool {
        matches!(self, Verdict::Reject(_))
    }

    /// The reason, if this is a refusal.
    pub fn reason(&self) -> Option<&str> {
        match self {
            Verdict::Reject(rejection) => Some(&rejection.reason),
            Verdict::Allow => None,
        }
    }

    /// The reference at fault, if this is a refusal about one reference.
    pub fn ref_name(&self) -> Option<&str> {
        match self {
            Verdict::Reject(rejection) => rejection.ref_name.as_deref(),
            Verdict::Allow => None,
        }
    }
}

/// One check that runs between the pack landing and the references moving.
///
/// The signature is fixed by the pre-receive gate's design and is deliberately narrow: a gate sees a
/// [`PushContext`] and returns a [`Verdict`]. It cannot write, cannot move a
/// reference, and cannot decide half a push.
pub trait Gate: Send + Sync {
    /// A stable identifier, used in rejection messages, in `PushRejected` events
    /// (the event stream) and by `Policy.ListGates`.
    fn name(&self) -> &str;

    /// Decide.
    ///
    /// Returning `Err` means *the gate could not decide*, which
    /// [`PolicyEngine::evaluate`] treats as a refusal, never as an allow. See
    /// [`error`].
    fn check(&self, ctx: &PushContext<'_>) -> Result<Verdict>;

    /// Does this gate read [`PushContext::new_objects`]?
    ///
    /// `true` unless a gate says otherwise, because that is the safe answer:
    /// receive-pack asks the ingest for the pushed object set, and refuses a
    /// push whose set cannot be enumerated, whenever any live gate reads it. A
    /// gate that decides from the repository's references and the object graph
    /// alone ([`AiTrailerGate`]) says `false`, so being on by default does not
    /// make every push pay for — or, on an engine that cannot produce one, be
    /// refused for want of — an enumeration nothing reads.
    fn reads_new_objects(&self) -> bool {
        true
    }
}

impl<T: Gate + ?Sized> Gate for Box<T> {
    fn name(&self) -> &str {
        (**self).name()
    }
    fn check(&self, ctx: &PushContext<'_>) -> Result<Verdict> {
        (**self).check(ctx)
    }
    fn reads_new_objects(&self) -> bool {
        (**self).reads_new_objects()
    }
}

impl<T: Gate + ?Sized> Gate for std::sync::Arc<T> {
    fn name(&self) -> &str {
        (**self).name()
    }
    fn check(&self, ctx: &PushContext<'_>) -> Result<Verdict> {
        (**self).check(ctx)
    }
    fn reads_new_objects(&self) -> bool {
        (**self).reads_new_objects()
    }
}

/// A refusal, with the gate that produced it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GateRejection {
    /// Which gate refused.
    pub gate: String,
    /// The reference at fault, if any. See [`Rejection::ref_name`].
    pub ref_name: Option<String>,
    /// Why.
    pub reason: String,
    /// `true` when the gate returned an error rather than a verdict: it could
    /// not decide, and the push was refused on that basis. Surfaced separately
    /// because an operator wants to know the difference between "your push
    /// violates a rule" and "gunnar is broken".
    pub undecided: bool,
}

impl std::fmt::Display for GateRejection {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match &self.ref_name {
            Some(name) => write!(f, "[{}] {name}: {}", self.gate, self.reason),
            None => write!(f, "[{}] {}", self.gate, self.reason),
        }
    }
}

/// The outcome of running every gate.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Decision {
    /// Every refusal, in gate order. Empty means the push may proceed.
    pub rejections: Vec<GateRejection>,
}

impl Decision {
    /// `true` if nothing objected.
    pub fn is_allowed(&self) -> bool {
        self.rejections.is_empty()
    }

    /// The refusals that name a reference, keyed by that reference.
    ///
    /// This is what `report-status-v2` needs: one `ng <ref> <reason>` line per
    /// refused reference.
    pub fn rejected_refs(&self) -> std::collections::BTreeMap<&str, &GateRejection> {
        let mut out = std::collections::BTreeMap::new();
        for rejection in &self.rejections {
            if let Some(name) = &rejection.ref_name {
                out.entry(name.as_str()).or_insert(rejection);
            }
        }
        out
    }

    /// A one-line summary for a log or a sideband message.
    pub fn summary(&self) -> String {
        if self.is_allowed() {
            "allowed".to_owned()
        } else {
            self.rejections
                .iter()
                .map(|r| r.to_string())
                .collect::<Vec<_>>()
                .join("; ")
        }
    }
}

/// Every gate a repository has, run in order.
pub struct PolicyEngine {
    gates: Vec<Box<dyn Gate>>,
}

impl PolicyEngine {
    /// An engine over `gates`.
    pub fn new(gates: Vec<Box<dyn Gate>>) -> Self {
        PolicyEngine { gates }
    }

    /// The engine a [`PolicyConfig`] asks for.
    pub fn from_config(config: &PolicyConfig) -> Self {
        PolicyEngine::new(config.gates())
    }

    /// The names of the live gates. This is the control plane's `Policy.ListGates`.
    ///
    /// Worth surfacing because "which gates are actually running" is otherwise
    /// unanswerable from outside, and a repository whose protection rules failed
    /// to load would look exactly like one that has none.
    pub fn gate_names(&self) -> Vec<&str> {
        self.gates.iter().map(|g| g.name()).collect()
    }

    /// How many gates are live.
    pub fn len(&self) -> usize {
        self.gates.len()
    }

    /// `true` if no gate is live. Such an engine allows everything.
    pub fn is_empty(&self) -> bool {
        self.gates.is_empty()
    }

    /// `true` if any live gate reads the pushed object set. See
    /// [`Gate::reads_new_objects`].
    pub fn reads_new_objects(&self) -> bool {
        self.gates.iter().any(|g| g.reads_new_objects())
    }

    /// Run every gate.
    ///
    /// All gates run, even after one refuses, so the pusher learns everything
    /// wrong with the push in one round trip rather than one problem per attempt.
    ///
    /// When a gate refuses over a particular reference, the gate is re-run
    /// against each reference on its own to find *every* reference it objects to.
    /// That happens only on the refusal path, costs one extra pass, and is what
    /// lets `report-status-v2` put a reason on each refused line without widening
    /// the [`Gate`] trait the specification fixes.
    pub fn evaluate(&self, ctx: &PushContext<'_>) -> Decision {
        let mut decision = Decision::default();
        for gate in &self.gates {
            match gate.check(ctx) {
                Ok(Verdict::Allow) => {}
                Ok(Verdict::Reject(rejection)) => {
                    if rejection.ref_name.is_some() && ctx.updates.len() > 1 {
                        decision
                            .rejections
                            .extend(self.per_ref(gate.as_ref(), ctx, &rejection));
                    } else {
                        decision.rejections.push(GateRejection {
                            gate: gate.name().to_owned(),
                            ref_name: rejection.ref_name,
                            reason: rejection.reason,
                            undecided: false,
                        });
                    }
                }
                Err(err) => {
                    tracing::warn!(
                        gate = gate.name(),
                        error = %err,
                        "gate could not decide; refusing the push"
                    );
                    decision.rejections.push(GateRejection {
                        gate: gate.name().to_owned(),
                        ref_name: None,
                        reason: format!("gate could not decide: {err}"),
                        undecided: true,
                    });
                }
            }
        }
        decision
    }

    /// Attribute a reference-scoped refusal to every reference it applies to.
    fn per_ref(
        &self,
        gate: &dyn Gate,
        ctx: &PushContext<'_>,
        first: &Rejection,
    ) -> Vec<GateRejection> {
        let mut out = Vec::new();
        for index in 0..ctx.updates.len() {
            let narrowed = ctx.narrowed_to(index);
            match gate.check(&narrowed) {
                Ok(Verdict::Reject(rejection)) => out.push(GateRejection {
                    gate: gate.name().to_owned(),
                    ref_name: rejection
                        .ref_name
                        .or_else(|| Some(ctx.updates[index].name.clone())),
                    reason: rejection.reason,
                    undecided: false,
                }),
                Ok(Verdict::Allow) => {}
                Err(err) => out.push(GateRejection {
                    gate: gate.name().to_owned(),
                    ref_name: Some(ctx.updates[index].name.clone()),
                    reason: format!("gate could not decide: {err}"),
                    undecided: true,
                }),
            }
        }
        if out.is_empty() {
            // The narrowed runs disagreed with the whole-push run. That should be
            // impossible for every gate in this crate, and swallowing it would
            // turn a refusal into an allow, so keep the original.
            out.push(GateRejection {
                gate: gate.name().to_owned(),
                ref_name: first.ref_name.clone(),
                reason: first.reason.clone(),
                undecided: false,
            });
        }
        out
    }
}

impl std::fmt::Debug for PolicyEngine {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PolicyEngine")
            .field("gates", &self.gate_names())
            .finish()
    }
}