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