gunnar-server 3.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
//! **The registry's one rule** (roadmap phase 4).
//!
//! > No artifact without valid, logged provenance.
//!
//! That sentence is the product, and this module is it as a function. Whatever
//! the registry eventually speaks — the OCI Distribution API, a cargo sparse
//! index, both — the thing that decides whether bytes may land is this, and it
//! is one place so the two surfaces cannot come to disagree (one implementation).
//!
//! # Why the check is here and not in the transport
//!
//! Because a rule that lives in a handler is a rule each handler has to
//! remember, and the second handler is where it gets forgotten. `admit` takes
//! the bytes' digest and the server's stores; a transport that skips calling it
//! is a transport that does not compile against this module's only entry point.
//!
//! # It refuses in four different ways, and they are four different sentences
//!
//! An operator's next action is different for each, so collapsing them into
//! "rejected" would make the error useless:
//!
//! * nothing has attested to this artifact — build it through the runner;
//! * a record exists and does not verify — somebody forged it, or the builder's
//!   key rotated;
//! * the record is not in the log — the builder appended nothing;
//! * the record is in the log and no signed head covers it — run
//!   `gunnar attest head --builds`.
//!
//! The fourth is the one worth being pedantic about: an inclusion proof with
//! nothing signed over it is arithmetic the server did about itself, and
//! admitting on it would make the log decorative.

use anyhow::{Context, Result};

use crate::stores::ServerStores;

/// Why an artifact may not be admitted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
    /// No build record claims to have produced it.
    NotAttested,
    /// A record exists and its signature does not verify.
    ProvenanceForged(String),
    /// The record is not in the build log.
    NotLogged,
    /// The record is in the log and nothing has signed over the tree it is in.
    NotUnderASignedHead { index: usize, size: usize },
    /// The log's records no longer hash to what a signed head swore to.
    LogRewritten { signed: String, actual: String },
}

impl std::fmt::Display for Refusal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Refusal::NotAttested => write!(
                f,
                "no build record claims to have produced this artifact. The registry does not \
                 take bytes on trust: build it through a builder that attests."
            ),
            Refusal::ProvenanceForged(why) => write!(
                f,
                "a build record names this artifact and does not verify: {why}"
            ),
            Refusal::NotLogged => write!(
                f,
                "the build record is not in the transparency log, so nobody outside this \
                 server could ever check it"
            ),
            Refusal::NotUnderASignedHead { index, size } => write!(
                f,
                "the build record is record {index} of {size} in the log and no signed tree \
                 head covers it. An inclusion proof with nothing signed over it is arithmetic \
                 this server did about itself. Run `gunnar attest head --builds`."
            ),
            Refusal::LogRewritten { signed, actual } => write!(
                f,
                "THE BUILD LOG HAS BEEN REWRITTEN: a signed head swore to {signed} and the \
                 records now hash to {actual}"
            ),
        }
    }
}

/// What an admitted artifact came with.
#[derive(Debug, Clone)]
pub struct Admitted {
    /// The build record that covers it.
    pub record: gunnar_core::BuildRecord,
    /// Where it sits in the build log.
    pub index: usize,
    /// The tree head that covers it.
    pub head: gunnar_core::TreeHead,
}

/// **May these bytes land?**
///
/// `Ok(Admitted)` only when a build record names the digest, verifies against
/// the builder's key, sits in the build log, and is covered by a tree head this
/// server's log key signed. Anything less is an [`Refusal`] naming which of the
/// four it was.
pub fn admit(stores: &ServerStores, artifact_sha256: &str) -> Result<Result<Admitted, Refusal>> {
    let builds = stores.build_log();
    let builder_key = stores.provenance_key()?;
    let log_key = stores.transparency_key()?;

    let signed: Vec<gunnar_core::SignedBuild> = builds
        .lines_of()?
        .iter()
        .map(|line| serde_json::from_str(line).context("decoding a build record"))
        .collect::<Result<_>>()?;

    let Some(index) = signed
        .iter()
        .position(|build| build.record.produced(artifact_sha256))
    else {
        return Ok(Err(Refusal::NotAttested));
    };
    let build = &signed[index];

    if let Err(problem) = gunnar_core::provenance::verify_build(build, builder_key.public_key()) {
        return Ok(Err(Refusal::ProvenanceForged(problem.to_string())));
    }

    let Some(inclusion) = builds.prove(index)? else {
        return Ok(Err(Refusal::NotLogged));
    };
    let heads = builds.heads()?;
    let covering = heads.iter().rev().find(|head| {
        head.head.size as usize >= inclusion.size
            && gunnar_core::transparency::verify_tree_head(head, log_key.public_key()).is_ok()
    });
    let Some(head) = covering else {
        return Ok(Err(Refusal::NotUnderASignedHead {
            index: inclusion.index,
            size: inclusion.size,
        }));
    };
    if head.head.root != inclusion.root {
        return Ok(Err(Refusal::LogRewritten {
            signed: head.head.root.clone(),
            actual: inclusion.root,
        }));
    }

    Ok(Ok(Admitted {
        record: build.record.clone(),
        index,
        head: head.head.clone(),
    }))
}

// ── the wire ───────────────────────────────────────────────────────────────

#[cfg(feature = "http")]
mod wire {
    use super::*;

    /// **The OCI Distribution surface, backed by this server's stores.**
    ///
    /// `gunnar-http` owns the shape of the protocol and knows nothing about
    /// provenance; this owns the decision and knows nothing about HTTP. The seam
    /// between them is [`gunnar_http::registry::RegistryBackend`], and it exists so
    /// a transport cannot have an opinion about who may write — the same reason
    /// `GitTransport` and `LfsTransport` exist.
    pub struct StoreRegistry {
        stores: std::sync::Arc<ServerStores>,
        registry: crate::oci::Registry,
    }

    impl StoreRegistry {
        /// Serve the registry that lives under `stores`.
        pub fn new(stores: std::sync::Arc<ServerStores>) -> StoreRegistry {
            let registry = crate::oci::Registry::under(stores.root());
            StoreRegistry { stores, registry }
        }
    }

    impl gunnar_http::registry::RegistryBackend for StoreRegistry {
        fn blob(&self, digest: &str) -> std::result::Result<Option<Vec<u8>>, String> {
            self.registry
                .get_blob(digest)
                .map_err(|err| format!("{err:#}"))
        }

        fn manifest(
            &self,
            name: &str,
            reference: &str,
        ) -> std::result::Result<Option<(String, Vec<u8>)>, String> {
            let Some(digest) = self
                .registry
                .resolve(name, reference)
                .map_err(|err| format!("{err:#}"))?
            else {
                return Ok(None);
            };
            let Some(bytes) = self
                .registry
                .get_blob(&digest)
                .map_err(|err| format!("{err:#}"))?
            else {
                // A tag that resolves to a blob that is not there. Reported as
                // absent rather than as a fault, because that is what the client
                // can act on — but logged, because it means the store lost a blob a
                // tag still names.
                tracing::warn!(
                    name,
                    reference,
                    %digest,
                    "a tag resolves to a manifest that is not in the blob store"
                );
                return Ok(None);
            };
            Ok(Some((digest, bytes)))
        }

        fn put_blob(&self, bytes: &[u8]) -> std::result::Result<String, String> {
            self.registry
                .put_blob(bytes)
                .map_err(|err| format!("{err:#}"))
        }

        fn put_manifest(
            &self,
            name: &str,
            reference: &str,
            bytes: &[u8],
        ) -> std::result::Result<std::result::Result<String, String>, String> {
            match self
                .registry
                .put_manifest(&self.stores, name, reference, bytes)
                .map_err(|err| format!("{err:#}"))?
            {
                Ok(landed) => Ok(Ok(landed.digest)),
                // The refusal's own sentence, not a code: it is what an operator
                // reads out of `docker push` and it says what to do next.
                Err(refusal) => Ok(Err(format!("{refusal}"))),
            }
        }
    }
}

#[cfg(feature = "http")]
pub use wire::StoreRegistry;