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

//! What a gate gets to look at.
//!
//! Everything here is **borrowed**. receive-pack owns the pack index, the ref
//! command list and the principal for the life of the push; the gate only reads
//! them. Borrowing rather than owning is also what makes
//! [`PushContext::narrowed_to`] free, and that is what lets the engine report
//! *which* references a gate objected to rather than only that it objected,
//! without changing the `Gate` trait the specification fixes.

use gix_hash::ObjectId;
use gunnar_store::git_storage_trait::{GitServe, ObjType};

/// Who is pushing.
///
/// This mirrors `Principal` in the identity table. The key material lives in a
/// [`KeyDirectory`] rather than on the principal, because the signature gate has
/// to ask about keys belonging to *other* principals. A commit authored by
/// someone else and pushed by you is the normal case, not the exception.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Principal {
    /// Stable identifier. This is what `allowed_principals` matches against.
    pub id: String,
    /// Display name, for messages.
    pub name: String,
    /// What sort of actor this is.
    pub kind: PrincipalKind,
}

impl Principal {
    /// A user principal whose id and display name are the same.
    pub fn user(id: impl Into<String>) -> Self {
        let id = id.into();
        Principal {
            name: id.clone(),
            id,
            kind: PrincipalKind::User,
        }
    }

    /// A machine principal: CI, a deploy runner, a mirror.
    pub fn machine(id: impl Into<String>) -> Self {
        let id = id.into();
        Principal {
            name: id.clone(),
            id,
            kind: PrincipalKind::Machine,
        }
    }
}

/// The three kinds of principal in the identity table.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrincipalKind {
    /// A human.
    User,
    /// A robot with its own identity, such as CI or a mirror.
    Machine,
    /// A key scoped to one repository.
    Deploy,
}

/// Which repository is being pushed to.
///
/// Namespaced from day 1, because adding the field later means
/// migrating every stored path and every grant.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RepoId {
    /// The namespace, the `<ns>` in `git@host:<ns>/<repo>.git`.
    pub namespace: String,
    /// The repository name.
    pub name: String,
}

impl RepoId {
    /// A repository in a namespace.
    pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
        RepoId {
            namespace: namespace.into(),
            name: name.into(),
        }
    }
}

impl std::fmt::Display for RepoId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}/{}", self.namespace, self.name)
    }
}

/// One line of receive-pack's command list: `<old-oid> <new-oid> <refname>`.
///
/// # Why `Option<ObjectId>` and not a null oid
///
/// On the wire, "this reference did not exist" and "delete this reference" are
/// both spelled as an all-zero oid. Keeping that spelling inside the policy layer
/// is the [identity-value trap](../index.html): `old == new` is true for two
/// creates of different references, `old.is_null()` reads as a perfectly ordinary
/// oid comparison, and an ancestry check handed a null oid walks nothing and
/// finds nothing, which looks exactly like a legitimate answer.
///
/// So the wire spelling is decoded exactly once, in [`RefUpdate::from_wire`], and
/// never appears again.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RefUpdate {
    /// The reference being changed.
    ///
    /// A `String`, and **validated by the caller** — receive-pack, through
    /// `gunnar_store::refname::validate`, which is where a client-supplied name
    /// first arrives. It was a `gunnar_store::FullName` until 2026-08-10: a
    /// newtype that could not be constructed without passing the rules. That
    /// type went with `RefStore`, and `GitOps` spells reference names as
    /// `String` throughout (`RefRow::name`, `RefUpdate::name`).
    ///
    /// **What that costs, stated rather than glossed:** the "already validated"
    /// promise is no longer carried by the type, so it is a rule about the
    /// caller instead of a property of the value.
    /// `refs/heads/../../etc/passwd` is the case that matters, and the gate
    /// layer is not where it is caught.
    pub name: String,
    /// What it points at now. `None` means it does not exist yet.
    pub old: Option<ObjectId>,
    /// What it should point at. `None` means delete.
    pub new: Option<ObjectId>,
}

/// What an update does, once the null oids are decoded.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UpdateKind {
    /// The reference did not exist and will.
    Create,
    /// The reference existed and moves.
    Update,
    /// The reference existed and will not.
    Delete,
    /// Neither side exists. Nonsensical, and rejected on sight.
    Noop,
}

impl RefUpdate {
    /// Create: `old` did not exist.
    pub fn create(name: String, new: ObjectId) -> Self {
        RefUpdate {
            name,
            old: None,
            new: Some(new),
        }
    }

    /// Move `name` from `old` to `new`.
    pub fn update(name: String, old: ObjectId, new: ObjectId) -> Self {
        RefUpdate {
            name,
            old: Some(old),
            new: Some(new),
        }
    }

    /// Remove `name`, which currently points at `old`.
    pub fn delete(name: String, old: ObjectId) -> Self {
        RefUpdate {
            name,
            old: Some(old),
            new: None,
        }
    }

    /// Decode a receive-pack command line, where the null oid means "absent".
    ///
    /// This is the *only* place the null-oid spelling is understood.
    pub fn from_wire(name: String, old: ObjectId, new: ObjectId) -> Self {
        RefUpdate {
            name,
            old: (!old.is_null()).then_some(old),
            new: (!new.is_null()).then_some(new),
        }
    }

    /// What this update does.
    pub fn kind(&self) -> UpdateKind {
        match (&self.old, &self.new) {
            (None, Some(_)) => UpdateKind::Create,
            (Some(_), Some(_)) => UpdateKind::Update,
            (Some(_), None) => UpdateKind::Delete,
            (None, None) => UpdateKind::Noop,
        }
    }

    /// `true` if this update removes the reference.
    pub fn is_delete(&self) -> bool {
        self.kind() == UpdateKind::Delete
    }
}

/// The public keys gunnar will accept a signature from.
///
/// A trait rather than a `Vec` because the real directory is gunnar's key table
/// and is not something the policy crate should own. The two questions
/// a gate asks are deliberately different: *may this principal have signed it*,
/// and *does anyone gunnar knows own this key*.
pub trait KeyDirectory: Send + Sync {
    /// Keys registered to `principal_id`.
    fn keys_for(&self, principal_id: &str) -> Vec<ssh_key::PublicKey>;

    /// Every key gunnar knows about, across all principals.
    fn all_keys(&self) -> Vec<ssh_key::PublicKey>;
}

/// A [`KeyDirectory`] over an in-memory table: the test and single-tenant impl.
#[derive(Clone, Debug, Default)]
pub struct StaticKeyDirectory {
    by_principal: std::collections::BTreeMap<String, Vec<ssh_key::PublicKey>>,
    /// **Keys only the server holds** (the merge-signing key, GUN-MERGE-SIGN): never a
    /// principal's, so no push can sign with them; whatever they signed the server itself
    /// made (its merge commits). They count for every principal and every scope.
    server: Vec<ssh_key::PublicKey>,
}

impl StaticKeyDirectory {
    /// An empty directory. Note that with `require_signature` on, an empty
    /// directory rejects *every* signed push (there is no key to match), which
    /// is the correct direction to fail in.
    pub fn new() -> Self {
        Self::default()
    }

    /// Register `key` to `principal_id`.
    pub fn add(&mut self, principal_id: impl Into<String>, key: ssh_key::PublicKey) -> &mut Self {
        self.by_principal
            .entry(principal_id.into())
            .or_default()
            .push(key);
        self
    }

    /// Trust `key` as a **server key** for every principal (see the field).
    pub fn add_server_key(&mut self, key: ssh_key::PublicKey) -> &mut Self {
        self.server.push(key);
        self
    }

    /// Builder form of [`StaticKeyDirectory::add`].
    pub fn with(mut self, principal_id: impl Into<String>, key: ssh_key::PublicKey) -> Self {
        self.add(principal_id, key);
        self
    }
}

impl KeyDirectory for StaticKeyDirectory {
    fn keys_for(&self, principal_id: &str) -> Vec<ssh_key::PublicKey> {
        let mut keys = self
            .by_principal
            .get(principal_id)
            .cloned()
            .unwrap_or_default();
        keys.extend(self.server.iter().cloned());
        keys
    }

    fn all_keys(&self) -> Vec<ssh_key::PublicKey> {
        self.by_principal
            .values()
            .flatten()
            .chain(self.server.iter())
            .cloned()
            .collect()
    }
}

impl<T: KeyDirectory + ?Sized> KeyDirectory for std::sync::Arc<T> {
    fn keys_for(&self, principal_id: &str) -> Vec<ssh_key::PublicKey> {
        (**self).keys_for(principal_id)
    }
    fn all_keys(&self) -> Vec<ssh_key::PublicKey> {
        (**self).all_keys()
    }
}

/// Everything a gate may look at, and nothing it may change.
///
/// Built by receive-pack between "the pack is indexed" and "the refs are
/// applied" (the receive-pack sequence, steps 4 → 5).
#[derive(Clone, Copy)]
pub struct PushContext<'a> {
    /// Who authenticated over SSH. Identity comes from the key, not the SSH
    /// username — identity comes from the key.
    pub principal: &'a Principal,
    /// Which repository.
    pub repo: &'a RepoId,
    /// Every `<old> <new> <name>` from the command list.
    pub updates: &'a [RefUpdate],
    /// Every object the pushed pack introduced that the store did not already
    /// hold: commits, trees, blobs and tags alike.
    ///
    /// # Two deliberate departures from the specification
    ///
    /// The pre-receive gate's design says the context carries "the set of newly
    /// introduced **commits**".
    /// It carries all newly introduced *objects* instead, and derives the commits
    /// from them ([`PushContext::new_commits`]), for two reasons:
    ///
    /// 1. The size/count gate has to see blobs. "Reject a 4 GB blob before it
    ///    lands" is not answerable from a commit list.
    /// 2. Two lists that must agree is the shape one implementation exists
    ///    to prevent. One list and one derivation cannot disagree.
    ///
    /// It is also a **required** constructor argument rather than a builder
    /// option, because a defaulted-empty object set is an always-allow switch:
    /// every gate that reasons about new content would pass a push whose caller
    /// simply forgot to fill it in, and no test that supplies the field could
    /// ever notice. A delete-only push passes `&[]`, and says so.
    pub new_objects: &'a [ObjectId],
    /// Read access to everything, old and new. The pack is already indexed, so
    /// the new objects are readable through here.
    ///
    /// **[`GitServe`] and not [`GitOps`](gunnar_store::git_storage_trait::GitOps),
    /// and the reason is not that this is a wire concern — it is not one.** A
    /// gate needs the object *inflated and delta-resolved*; `GitOps::get`
    /// hands back `Stored::bytes`, *"the pack entry, header and all, byte for
    /// byte as the client sent them"*, which for the ordinary push is a delta.
    /// Branch protection's ancestry walk and the signature gate were three of
    /// the eight errors that blocked the whole workspace on 2026-08-10, and
    /// they were the *same* missing capability the wire stopped on. That is
    /// what makes `GitServe` the **reading** contract rather than a serving
    /// one (`locked_on_target.md`, the `GitServe` reading contract).
    pub objects: &'a dyn GitServe,
    /// The keys gunnar will accept signatures from.
    pub keys: &'a dyn KeyDirectory,
    /// How many bytes the pushed pack was on the wire, if receive-pack measured
    /// it. `None` means unmeasured, and a byte limit configured against it will
    /// say so rather than silently pass.
    pub pack_bytes: Option<u64>,
    /// The `push-option` lines the client sent, **if receive-pack reported
    /// them**.
    ///
    /// # Why `Option<&[…]>` and not `&[…]`
    ///
    /// The same reason as [`PushContext::pack_bytes`], and the same reason
    /// [`PushContext::new_objects`] is a required constructor argument: an
    /// empty slice would mean two different things. "the client sent no push
    /// options" is a fact a gate may act on; "whoever built this context never
    /// wired the options through" is not, and an allowlist gate handed the
    /// second one would find nothing to object to and pass every push.
    ///
    /// That is not hypothetical here: the options were advertised, parsed and
    /// then dropped on the floor for the whole of v1 — the only consumer in the
    /// tree was `.len()` in a log line — so the unwired caller is the state
    /// this field was added out of.
    ///
    /// `Some(&[])` is a measured "none sent". `None` makes a configured
    /// push-option gate REFUSE, saying so, rather than allow.
    pub push_options: Option<&'a [String]>,
}

impl<'a> PushContext<'a> {
    /// The minimum a gate needs.
    ///
    /// `new_objects` is required rather than defaulted. See the field's
    /// documentation for why an empty default would be a silent always-allow.
    pub fn new(
        principal: &'a Principal,
        repo: &'a RepoId,
        updates: &'a [RefUpdate],
        new_objects: &'a [ObjectId],
        objects: &'a dyn GitServe,
        keys: &'a dyn KeyDirectory,
    ) -> Self {
        PushContext {
            principal,
            repo,
            updates,
            new_objects,
            objects,
            keys,
            pack_bytes: None,
            push_options: None,
        }
    }

    /// The newly introduced **commits**: the gate design's field, derived rather than
    /// stored.
    ///
    /// One header read per new object, no payload reads: exactly the access the
    /// `object_type` base column of the `git` znippy package exists to make free.
    pub fn new_commits(&self) -> crate::Result<Vec<ObjectId>> {
        let mut out = Vec::new();
        for id in self.new_objects {
            if let Some((kind, _size)) = self
                .objects
                .header(id.as_bytes())
                .map_err(crate::error::Error::backend)?
            {
                if kind == ObjType::Commit {
                    out.push(*id);
                }
            }
        }
        Ok(out)
    }

    /// The new objects as a set, for walks that must stop at pre-existing
    /// history.
    pub fn new_object_set(&self) -> std::collections::HashSet<ObjectId> {
        self.new_objects.iter().copied().collect()
    }

    /// Attach the measured on-the-wire pack size.
    pub fn with_pack_bytes(mut self, bytes: u64) -> Self {
        self.pack_bytes = Some(bytes);
        self
    }

    /// Attach the push options receive-pack read off the wire.
    ///
    /// An empty slice is a legitimate argument and means "the client sent
    /// none". Not calling this at all means "nobody looked", which is a
    /// different answer — see [`PushContext::push_options`].
    pub fn with_push_options(mut self, options: &'a [String]) -> Self {
        self.push_options = Some(options);
        self
    }

    /// The same push, restricted to one reference update.
    ///
    /// The engine uses this to turn "some gate said no" into "these references
    /// were refused, each with its reason", which is what `report-status-v2`
    /// needs per ref. It is a borrow reslice, so it costs nothing.
    pub fn narrowed_to(&self, index: usize) -> PushContext<'a> {
        let mut narrowed = *self;
        narrowed.updates = &self.updates[index..index + 1];
        narrowed
    }
}

impl std::fmt::Debug for PushContext<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PushContext")
            .field("principal", &self.principal.id)
            .field("repo", &self.repo.to_string())
            .field("updates", &self.updates)
            .field("new_objects", &self.new_objects.len())
            .field("pack_bytes", &self.pack_bytes)
            .finish_non_exhaustive()
    }
}

#[cfg(test)]
mod server_key_tests {
    use super::*;

    fn key(seed: u8) -> ssh_key::PublicKey {
        ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[seed; 32]))
            .public_key()
            .clone()
    }

    /// ★ GUN-MERGE-SIGN: the server's merge-signing key counts for every principal and in
    /// every scope (it signs only the server's own merge commits), and is no principal's.
    #[test]
    fn a_server_key_counts_for_every_principal_and_in_all_keys() {
        let mut directory = StaticKeyDirectory::new();
        directory.add("alice", key(1));
        directory.add_server_key(key(9));
        assert!(
            directory.keys_for("alice").contains(&key(9)),
            "alice's scope"
        );
        assert!(
            directory.keys_for("bob").contains(&key(9)),
            "a principal with no key of its own"
        );
        assert!(directory.all_keys().contains(&key(9)), "any registered key");
        assert!(
            !directory.keys_for("bob").contains(&key(1)),
            "alice's key stays hers"
        );
    }
}