crates/nornir-gate/src/lib.rs
//! # `nornir-gate` — the verdict vocabulary a gate answers in
//!
//! One set of types for "a check ran and this is what it decided", shared by
//! every nordisk product that has gates. Nothing here knows what is being
//! judged: a gate's CONTEXT is the consumer's own type, because a git push and
//! an artifact promotion carry different things.
//!
//! ## Where it comes from
//!
//! gunnar's pre-receive policy layer (`gunnar/crates/gunnar-policy/src/lib.rs`)
//! defined `Verdict`, `Rejection`, `GateRejection` and `Decision` and got the
//! semantics right. holger needs the same four types for promotion gates.
//! Copying them would produce two vocabularies that drift; making holger depend
//! on `gunnar-policy` (or the reverse) would couple two products that have no
//! reason to know about each other. So they are lifted here, into edda, which
//! is where the suite puts shared types precisely to avoid that coupling.
//!
//! The only generalisation applied: gunnar's `ref_name` is spelled `subject`,
//! because a promotion gate's refusal is attributable to an ARTIFACT, not a git
//! reference. gunnar's meaning is unchanged; it passes a reference name.
//!
//! ## The four rules the types encode
//!
//! 1. **`Err` is not `Allow`.** A gate that returns an error did not decide, and
//! an undecided gate REFUSES. [`GateRejection::undecided`] keeps that
//! distinguishable from a rule violation, because an operator needs to know
//! the difference between "your request breaks a rule" and "the server is
//! broken". [`Decision::undecided`] reports it.
//!
//! 2. **Every gate runs.** [`evaluate`] does not stop at the first refusal. One
//! round trip should tell the caller everything that is wrong, not the first
//! thing.
//!
//! 3. **Gates are named, and the names are listable.** gunnar exposes
//! `gate_names()` over RPC because, in its own words, *"a repository whose
//! protection rules failed to load would look exactly like one that has
//! none"*. Any consumer of these types should expose the same list.
//!
//! 4. **GUN-007 — an unreadable gate configuration refuses everything.** This
//! is a rule about the CONSUMER, not about a type here, so it cannot be
//! enforced by this crate; it is written down here because it is the reason
//! the crate exists in this shape. gunnar's history
//! (`gunnar/crates/gunnar-transport/src/transport.rs:2570`): until
//! 2026-09-11 a `load_policy` error fell back to `PolicyConfig::default()`,
//! so a typo in the policy file silently turned branch protection off. The
//! fix was to refuse every reference on that error. A consumer of this crate
//! must do the same: a gate list that cannot be resolved refuses the
//! operation, and the refusal must cost only that operation — reads and
//! fetches stay up.
//!
//! ## No OS, no filesystem, no clock
//!
//! `Verdict` and `Decision` travel into appliance builds and into sandboxes.
//! This crate depends on nothing but `std`, so it links anywhere.
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::fmt;
/// What one gate decided.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
/// Nothing to object to. This is the ONLY way through.
Allow,
/// The request, or one subject in it, is refused.
Reject(Rejection),
}
impl Verdict {
/// Refuse, attributing the refusal to one subject.
pub fn reject(subject: impl Into<String>, reason: impl Into<String>) -> Verdict {
Verdict::Reject(Rejection {
subject: Some(subject.into()),
reason: reason.into(),
})
}
/// Refuse the whole request, with no single subject at fault.
pub fn reject_all(reason: impl Into<String>) -> Verdict {
Verdict::Reject(Rejection { subject: None, reason: reason.into() })
}
pub fn is_allowed(&self) -> bool {
matches!(self, Verdict::Allow)
}
/// The subject at fault, when the refusal names one.
pub fn subject(&self) -> Option<&str> {
match self {
Verdict::Reject(r) => r.subject.as_deref(),
Verdict::Allow => None,
}
}
}
/// Why something was refused, and what it was about.
///
/// `subject` is `Some` whenever the refusal is attributable to one item, so a
/// caller can put the reason on the right line of a per-item report. It is
/// `None` for request-wide refusals such as a count ceiling.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rejection {
/// The item at fault: a git reference name for gunnar, an artifact
/// coordinate or digest for holger.
pub subject: Option<String>,
/// A message fit to send back over the wire. It must name what was refused
/// and which rule refused it. "rejected by policy" wastes the one round
/// trip the caller gets.
pub reason: String,
}
/// One gate's refusal, tagged with which gate produced it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GateRejection {
/// Which gate refused. This is `Gate::name()` in the consumer's trait.
pub gate: String,
/// The subject at fault, if any.
pub subject: Option<String>,
/// Why.
pub reason: String,
/// `true` when the gate returned an error rather than a verdict: it could
/// not decide, and the request was refused on that basis.
pub undecided: bool,
}
impl fmt::Display for GateRejection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let undecided = if self.undecided { " (undecided)" } else { "" };
match &self.subject {
Some(s) => write!(f, "[{}]{undecided} {s}: {}", self.gate, self.reason),
None => write!(f, "[{}]{undecided} {}", 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 request may proceed.
pub rejections: Vec<GateRejection>,
/// The gates that ran, in order, whether they allowed or refused. Recorded
/// so an allow is evidence that something actually ran, rather than
/// evidence that no gate was configured.
pub gates_run: Vec<String>,
}
impl Decision {
/// `true` if nothing objected.
pub fn is_allowed(&self) -> bool {
self.rejections.is_empty()
}
/// `true` if at least one gate could not decide. A caller that reports to a
/// human should say so: it is an operational fault, not a policy violation.
pub fn undecided(&self) -> bool {
self.rejections.iter().any(|r| r.undecided)
}
/// The refusals that name a subject, keyed by that subject. First refusal
/// per subject wins, in gate order.
pub fn rejected_subjects(&self) -> BTreeMap<&str, &GateRejection> {
let mut out = BTreeMap::new();
for r in &self.rejections {
if let Some(s) = &r.subject {
out.entry(s.as_str()).or_insert(r);
}
}
out
}
/// One line for a log or a wire message.
pub fn summary(&self) -> String {
if self.is_allowed() {
return format!(
"allowed by {} gate(s): {}",
self.gates_run.len(),
if self.gates_run.is_empty() {
"none configured".to_string()
} else {
self.gates_run.join(", ")
}
);
}
self.rejections
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>()
.join("; ")
}
}
/// Run every gate and collect one [`Decision`].
///
/// `gates` yields `(name, result)` pairs, already evaluated by the caller — the
/// caller owns the context type, so it owns the call. This function owns the
/// two rules that must not be re-derived per product: **every gate runs**, and
/// **`Err` becomes an undecided refusal**.
///
/// ```
/// use nornir_gate::{evaluate, Verdict};
/// let d = evaluate([
/// ("osv".to_string(), Ok(Verdict::Allow)),
/// ("provenance".to_string(), Err("advisory db unreadable".to_string())),
/// ]);
/// assert!(!d.is_allowed());
/// assert!(d.undecided());
/// assert_eq!(d.gates_run, vec!["osv", "provenance"]);
/// ```
pub fn evaluate<I, E>(gates: I) -> Decision
where
I: IntoIterator<Item = (String, Result<Verdict, E>)>,
E: fmt::Display,
{
let mut d = Decision::default();
for (name, result) in gates {
d.gates_run.push(name.clone());
match result {
Ok(Verdict::Allow) => {}
Ok(Verdict::Reject(r)) => d.rejections.push(GateRejection {
gate: name,
subject: r.subject,
reason: r.reason,
undecided: false,
}),
Err(e) => d.rejections.push(GateRejection {
gate: name,
subject: None,
reason: format!("{e}"),
undecided: true,
}),
}
}
d
}
/// The refusal a consumer must produce when its gate configuration cannot be
/// resolved — GUN-007, as a `Decision` so it travels the same path as a real
/// verdict and cannot be mistaken for an allow.
///
/// `named` is the gate name that could not be resolved; `available` is what the
/// build does carry, so the operator is told what he could have written.
pub fn unresolvable_gate(named: &str, available: &[&str]) -> Decision {
Decision {
rejections: vec![GateRejection {
gate: named.to_string(),
subject: None,
reason: format!(
"gate '{named}' is named by the configuration and this build carries no gate by \
that name. A configuration that cannot be resolved refuses the operation rather \
than running with no gate at all; an operation with silently no gate looks \
exactly like one that passed. This build carries: {}",
if available.is_empty() { "no gates at all".to_string() } else { available.join(", ") }
),
undecided: true,
}],
gates_run: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_error_is_a_refusal_and_is_marked_undecided() {
let d = evaluate([("g".to_string(), Err::<Verdict, _>("boom"))]);
assert!(!d.is_allowed());
assert!(d.undecided());
assert_eq!(d.rejections[0].gate, "g");
}
#[test]
fn every_gate_runs_even_after_a_refusal() {
let d = evaluate([
("a".to_string(), Ok::<_, String>(Verdict::reject("x", "no"))),
("b".to_string(), Ok::<_, String>(Verdict::reject("y", "also no"))),
("c".to_string(), Ok::<_, String>(Verdict::Allow)),
]);
assert_eq!(d.rejections.len(), 2);
assert_eq!(d.gates_run, vec!["a", "b", "c"]);
}
#[test]
fn an_allow_records_which_gates_ran() {
let d = evaluate([("osv".to_string(), Ok::<_, String>(Verdict::Allow))]);
assert!(d.is_allowed());
assert!(d.summary().contains("osv"));
}
#[test]
fn no_gates_at_all_is_reported_as_such_rather_than_as_a_pass() {
let d = evaluate(Vec::<(String, Result<Verdict, String>)>::new());
assert!(d.is_allowed());
assert!(
d.summary().contains("none configured"),
"an allow with no gates must say so: {}",
d.summary()
);
}
#[test]
fn an_unresolvable_gate_name_refuses_and_lists_what_exists() {
let d = unresolvable_gate("provenance", &["osv"]);
assert!(!d.is_allowed());
assert!(d.undecided());
assert!(d.summary().contains("osv"));
}
#[test]
fn rejections_are_keyed_by_subject_in_gate_order() {
let d = evaluate([
("a".to_string(), Ok::<_, String>(Verdict::reject("art@1", "first"))),
("b".to_string(), Ok::<_, String>(Verdict::reject("art@1", "second"))),
]);
let by = d.rejected_subjects();
assert_eq!(by["art@1"].reason, "first");
}
}