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