crates/gunnar-policy/src/error.rs
//! What can go wrong *inside* a gate.
//!
//! # An error is not a rejection
//!
//! This distinction is the one thing to get right in this module. A
//! [`Verdict::Reject`](crate::Verdict::Reject) is a **policy** answer: the push
//! was well-formed and gunnar has decided not to take it. An [`Error`] is a
//! **gunnar** answer: the gate could not reach a decision at all, because an
//! object was missing, a pattern was malformed, or a walk ran out of budget.
//!
//! Conflating them is how a broken gate turns into a silent allow. A gate that
//! swallows "I could not read the commit" and returns `Allow` is exactly the
//! always-allow failure mode a measured assertion exists to catch. So every
//! gate in this crate propagates the error, and
//! [`PolicyEngine::evaluate`](crate::PolicyEngine::evaluate) treats an
//! erroring gate as a **rejection with the error as its reason**, never as an
//! allow.
use gix_hash::ObjectId;
/// The result type every gate returns.
pub type Result<T, E = Error> = std::result::Result<T, E>;
/// A gate could not reach a decision.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
/// The storage layer failed.
#[error("storage: {0}")]
Store(#[from] gunnar_store::Error),
/// A git object could not be decoded.
#[error("object {oid} could not be decoded: {reason}")]
Decode {
/// The object that would not decode.
oid: ObjectId,
/// Why.
reason: String,
},
/// An object the gate needs is not in the store.
///
/// At receive-pack time this should be impossible, since connectivity is
/// verified before the gate runs (the receive-pack sequence, step 4). So it means either a thin
/// pack whose bases did not resolve, or a caller that ran the gate too early.
/// Either way it is *inconclusive*, and inconclusive must never allow.
#[error("object {oid} is not in the store, so {what} cannot be decided")]
MissingObject {
/// The missing object.
oid: ObjectId,
/// What the gate was trying to decide.
what: &'static str,
},
/// An ancestry walk gave up before proving or disproving descent.
///
/// Rejecting here is deliberate: "I ran out of budget" is not "this is a
/// fast-forward".
#[error(
"walked {walked} commits from {from} without reaching {looking_for} \
and hit the {budget}-commit budget; ancestry is undecided"
)]
AncestryBudget {
/// Where the walk started: the pushed tip.
from: ObjectId,
/// The old tip it was looking for.
looking_for: ObjectId,
/// How many commits were visited.
walked: usize,
/// The budget that was hit.
budget: usize,
},
/// A configured reference pattern is not usable.
#[error("reference pattern {pattern:?} is invalid: {reason}")]
BadPattern {
/// The offending pattern.
pattern: String,
/// Why it was refused.
reason: &'static str,
},
/// Configuration could not be parsed.
#[error("policy configuration: {0}")]
Config(String),
}
impl Error {
/// A decode failure with a formatted reason.
pub(crate) fn decode(oid: impl Into<ObjectId>, reason: impl std::fmt::Display) -> Self {
Error::Decode {
oid: oid.into(),
reason: reason.to_string(),
}
}
/// **The one conversion from a storage fault into a gate error.**
///
/// `GitServe` returns `anyhow::Result` — the contract crate depends on
/// `anyhow` and nothing else, deliberately, so that neither backend has to
/// resolve the other's dependencies. Everything this crate can say about
/// such a fault is therefore "the storage layer failed, and here is what it
/// said", which is [`gunnar_store::Error::Backend`].
///
/// One function rather than a `map_err` at each of the six call sites, for
/// the reason one implementation gives: the day there is a typed rejection
/// to tell apart (`RefCas` / `RefLocked`, `locked_on_target.md`'s typed-CAS question)
/// there is exactly one place that has to learn about it. **Nothing
/// anywhere may tell two storage faults apart by matching on this
/// string.**
///
/// `{:#}` and not `{}`: `anyhow`'s alternate form prints the whole cause
/// chain, and the outermost message of a decode failure four layers down is
/// usually the least informative line in it.
pub(crate) fn backend(err: impl std::fmt::Display) -> Self {
Error::Store(gunnar_store::Error::backend(format!("{err:#}")))
}
}