gunnar-server 2.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
//! **An encrypted repository's push, checked in quarantine** (2026-10-06,
//! crypt-level2 item 4 pulled forward).
//!
//! On a repository flagged ENCRYPTED the server must never keep plaintext — not
//! on main's disk, not in the transaction log the twin replays. Until this
//! module the encrypted gate ran AFTER the pushed pack was framed and stored,
//! so a refused plaintext push moved no reference and still left its pack on
//! main until `gunnar gc` and in the twin's history (MEASURED on the estate by
//! the kit's CR4, 3 of 3 plaintext needles in both).
//!
//! Now the pack is held where the engine already holds it before it stores it
//! (the push's spool, in memory or beside the store and gone when the push
//! ends), resolved by znippy's one resolver against the repository's own
//! objects for a thin pack's bases, and put to the SAME walk the gate runs
//! ([`gunnar_policy::check_pushed`]). Only a pack that passes is framed and
//! stored; a refusal is [`Refused`], which the transport tells the client in
//! the gate's own words.

use std::collections::HashMap;
use std::sync::Mutex;

use anyhow::{Context as _, Result};
use gix_hash::ObjectId;
use git_storage_trait::{GitServe, ObjType};
use gunnar_policy::{PushedObjects, Shape, Verdict};
use znippy_plugin_git::exploded::PayloadSink;
use znippy_plugin_git::object::{GitHashKind, GitObjectKind};
use znippy_plugin_git::resolve::{resolve_walked, BaseSource};

/// **The push is refused, and nothing of it was kept**: the gate's sentence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refused(pub String);

impl std::fmt::Display for Refused {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for Refused {}

/// The refusal in `err`'s chain, if the quarantine refused.
pub fn refusal_in(err: &anyhow::Error) -> Option<String> {
    err.chain().find_map(|e| e.downcast_ref::<Refused>()).map(|r| r.0.clone())
}

/// What the quarantine keeps of one resolved object: a tree's or a commit's
/// bytes (the walk parses them), a blob's shape — and, for a small blob that
/// is not an envelope, its bytes, because it may be the key ring or the
/// members file the walk checks against each other (crypt-pq). Envelopes and
/// anything large are let go.
enum Held {
    Bytes(ObjType, Vec<u8>),
    Blob(Shape, Option<Vec<u8>>),
    Other(ObjType),
}

/// The largest blob kept for the ring check; a key ring or a members file is
/// far smaller (a quantum-safe stanza is ~1.6 KB).
const SCHEME_FILE_MAX: usize = gunnar_policy::MAX_SCHEME_FILE;

struct Quarantine {
    held: Mutex<HashMap<Vec<u8>, Held>>,
}

impl PayloadSink for Quarantine {
    fn explode(&self, oid: &[u8], kind: GitObjectKind, payload: &[u8]) -> Result<()> {
        let held = match kind {
            GitObjectKind::Blob => {
                let shape = Shape::of(payload);
                let small = !shape.envelope && payload.len() <= SCHEME_FILE_MAX;
                Held::Blob(shape, small.then(|| payload.to_vec()))
            }
            GitObjectKind::Tree => Held::Bytes(ObjType::Tree, payload.to_vec()),
            GitObjectKind::Commit => Held::Bytes(ObjType::Commit, payload.to_vec()),
            GitObjectKind::Tag => Held::Other(ObjType::Tag),
        };
        self.held.lock().map_err(|_| anyhow::anyhow!("the quarantine's lock is poisoned"))?.insert(oid.to_vec(), held);
        Ok(())
    }
}

/// A thin pack's bases: the repository's own objects.
struct Bases<'s>(&'s dyn GitServe);

impl BaseSource for Bases<'_> {
    fn content(&self, oid: &[u8]) -> Result<Option<(GitObjectKind, Vec<u8>)>> {
        let Some((kind, bytes)) = self.0.read(oid).context("reading a delta base from the repository")? else {
            return Ok(None);
        };
        let kind = match kind {
            ObjType::Commit => GitObjectKind::Commit,
            ObjType::Tree => GitObjectKind::Tree,
            ObjType::Blob => GitObjectKind::Blob,
            ObjType::Tag => GitObjectKind::Tag,
            ObjType::OfsDelta | ObjType::RefDelta => anyhow::bail!("the repository answered a delta for a resolved read"),
        };
        Ok(Some((kind, bytes)))
    }
}

/// The quarantine as the walk reads it; `repo` answers for a key ring or a
/// members file the push did not change.
struct Pushed<'q> {
    held: &'q HashMap<Vec<u8>, Held>,
    repo: &'q dyn GitServe,
}

impl PushedObjects for Pushed<'_> {
    fn kind(&self, id: &ObjectId) -> gunnar_policy::Result<Option<ObjType>> {
        Ok(self.held.get(id.as_bytes()).map(|h| match h {
            Held::Bytes(k, _) | Held::Other(k) => *k,
            Held::Blob(..) => ObjType::Blob,
        }))
    }
    fn bytes(&self, id: &ObjectId, what: &'static str) -> gunnar_policy::Result<Vec<u8>> {
        match self.held.get(id.as_bytes()) {
            Some(Held::Bytes(_, b)) => Ok(b.clone()),
            _ => Err(gunnar_policy::Error::MissingObject { oid: *id, what }),
        }
    }
    fn shape(&self, id: &ObjectId) -> gunnar_policy::Result<Shape> {
        match self.held.get(id.as_bytes()) {
            Some(Held::Blob(s, _)) => Ok(*s),
            _ => Err(gunnar_policy::Error::MissingObject { oid: *id, what: "a pushed blob" }),
        }
    }
    fn blob(&self, id: &ObjectId) -> gunnar_policy::Result<Option<Vec<u8>>> {
        let what = "the key ring or the members file";
        let store = |e: anyhow::Error| gunnar_policy::Error::Store(gunnar_store::Error::backend(format!("{e:#}")));
        match self.held.get(id.as_bytes()) {
            Some(Held::Blob(_, Some(bytes))) => Ok(Some(bytes.clone())),
            // Too large to keep, or an envelope: not a file the check reads.
            Some(Held::Blob(_, None)) => Ok(None),
            Some(_) => Err(gunnar_policy::Error::MissingObject { oid: *id, what }),
            None => {
                let size = self.repo.header(id.as_bytes()).map_err(store)?.map(|(_, n)| n);
                if size.is_some_and(|n| n > SCHEME_FILE_MAX as u64) {
                    return Ok(None);
                }
                self.repo
                    .read(id.as_bytes())
                    .map_err(store)?
                    .map(|(_, bytes)| Some(bytes))
                    .ok_or(gunnar_policy::Error::MissingObject { oid: *id, what })
            }
        }
    }
}

/// ★ **Check `pack` before a byte of it is kept**: `Err(`[`Refused`]`)` with
/// the gate's sentence when it would put plaintext on an encrypted repository;
/// `Ok(n)` with how many objects were checked otherwise. `repo` answers a thin
/// pack's bases.
pub fn check(pack: &[u8], hash_kind: gix_hash::Kind, repo: &dyn GitServe) -> Result<usize> {
    let hash = match hash_kind {
        gix_hash::Kind::Sha256 => GitHashKind::Sha256,
        _ => GitHashKind::Sha1,
    };
    let walked = znippy_plugin_git::pack_walk::walk(pack, hash.oid_len()).context("walking the pushed pack in quarantine")?;
    let quarantine = Quarantine { held: Mutex::new(HashMap::new()) };
    resolve_walked(pack, &walked, hash, 0, &Bases(repo), &quarantine).context("resolving the pushed pack in quarantine")?;
    let held = quarantine.held.into_inner().map_err(|_| anyhow::anyhow!("the quarantine's lock is poisoned"))?;
    let new: Vec<ObjectId> = held
        .keys()
        .map(|raw| ObjectId::try_from(raw.as_slice()).map_err(|_| anyhow::anyhow!("a {}-byte object id", raw.len())))
        .collect::<Result<_>>()?;
    match gunnar_policy::check_pushed(&new, &Pushed { held: &held, repo }).map_err(|e| anyhow::anyhow!("{e}"))? {
        Verdict::Allow => Ok(new.len()),
        refused => Err(Refused(verdict_words(&refused)).into()),
    }
}

/// The sentence of a refusing verdict.
fn verdict_words(v: &Verdict) -> String {
    match v {
        Verdict::Allow => String::new(),
        other => other.reason().map(str::to_string).unwrap_or_else(|| format!("{other:?}")),
    }
}

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

    #[test]
    fn a_refusal_is_found_through_the_context_the_engine_adds() {
        let err = anyhow::Error::from(Refused("encrypted repository: x is plaintext".into()))
            .context("framing the pushed pack in the transaction log");
        assert_eq!(refusal_in(&err).as_deref(), Some("encrypted repository: x is plaintext"));
        assert_eq!(refusal_in(&anyhow::anyhow!("disk full")), None);
    }
}