Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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